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/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/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/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/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index c27afdd9..21618f71 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; @@ -59,6 +62,8 @@ public class DocumentService { private final DocumentVersionService documentVersionService; private final AnnotationService annotationService; private final AuditService auditService; + private final TranscriptionBlockQueryService transcriptionBlockQueryService; + private final AuditLogQueryService auditLogQueryService; public record StoreResult(Document document, boolean isNew) {} @@ -344,7 +349,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 +368,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 +385,34 @@ 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) { + return transcriptionBlockQueryService.getCompletionStats(docIds); } private Sort resolveSort(DocumentSort sort, String dir) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionBlockQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionBlockQueryService.java new file mode 100644 index 00000000..945b45d7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionBlockQueryService.java @@ -0,0 +1,27 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.repository.CompletionStatsRow; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TranscriptionBlockQueryService { + + private final TranscriptionBlockRepository blockRepository; + + public Map getCompletionStats(List documentIds) { + if (documentIds.isEmpty()) return Map.of(); + Map result = new HashMap<>(); + for (CompletionStatsRow row : blockRepository.findCompletionStatsForDocuments(documentIds)) { + result.put(row.getDocumentId(), row.getCompletionPercentage()); + } + return result; + } +} 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); 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/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(); + } +} 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); } 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); + } +} 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..b3905d0e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java @@ -5,6 +5,7 @@ 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; @@ -30,6 +31,8 @@ class DocumentServiceSortTest { @Mock TagService tagService; @Mock DocumentVersionService documentVersionService; @Mock AnnotationService annotationService; + @Mock AuditLogQueryService auditLogQueryService; + @Mock TranscriptionBlockQueryService transcriptionBlockQueryService; @InjectMocks DocumentService documentService; // ─── searchDocuments — DATE sort ────────────────────────────────────────── @@ -56,8 +59,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 +81,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 +99,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..6a3aec0f 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; @@ -51,6 +53,8 @@ class DocumentServiceTest { @Mock DocumentVersionService documentVersionService; @Mock AnnotationService annotationService; @Mock AuditService auditService; + @Mock AuditLogQueryService auditLogQueryService; + @Mock TranscriptionBlockQueryService transcriptionBlockQueryService; @InjectMocks DocumentService documentService; // ─── deleteDocument ─────────────────────────────────────────────────────── @@ -1298,8 +1302,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 +1322,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 +1345,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 +1366,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 +1380,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 +1399,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 } diff --git a/frontend/src/lib/components/ContributorStack.svelte b/frontend/src/lib/components/ContributorStack.svelte index fc627627..95919ec1 100644 --- a/frontend/src/lib/components/ContributorStack.svelte +++ b/frontend/src/lib/components/ContributorStack.svelte @@ -11,12 +11,17 @@ interface Props { let { contributors, hasMore }: Props = $props(); const safeContributors = $derived(contributors ?? []); + +function safeColor(color: string): string { + return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3'; +} {#if safeContributors.length === 0} {:else} @@ -24,8 +29,8 @@ const safeContributors = $derived(contributors ?? []); {actor.initials} @@ -33,7 +38,9 @@ const safeContributors = $derived(contributors ?? []); {/each} {#if hasMore} diff --git a/frontend/src/lib/components/ContributorStack.svelte.spec.ts b/frontend/src/lib/components/ContributorStack.svelte.spec.ts index 877167cd..4bb847c9 100644 --- a/frontend/src/lib/components/ContributorStack.svelte.spec.ts +++ b/frontend/src/lib/components/ContributorStack.svelte.spec.ts @@ -45,6 +45,8 @@ describe('ContributorStack', () => { it('renders empty placeholder when no contributors', async () => { render(ContributorStack, { contributors: [], hasMore: false }); - await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument(); + await expect + .element(page.getByRole('img', { name: 'Noch niemand angefangen' })) + .toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte b/frontend/src/lib/components/DashboardResumeStrip.svelte index e9c7620f..76eade81 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte @@ -7,6 +7,10 @@ interface Props { } const { resumeDoc }: Props = $props(); + +function safeColor(color: string): string { + return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3'; +} {#if resumeDoc === null} @@ -94,7 +98,7 @@ const { resumeDoc }: Props = $props(); {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)} {collab.initials}{collab.initials} {/each} diff --git a/frontend/src/lib/components/DocumentRow.svelte b/frontend/src/lib/components/DocumentRow.svelte new file mode 100644 index 00000000..bee37ac5 --- /dev/null +++ b/frontend/src/lib/components/DocumentRow.svelte @@ -0,0 +1,182 @@ + + +
  • + +
    + +
    + +

    + {#each titleSegments as seg, i (i)} + {#if seg.highlight} + {seg.text} + {:else} + {seg.text} + {/if} + {/each} +

    + + + {#if snippetSegments} +

    + {#each snippetSegments as seg, i (i)} + {#if seg.highlight} + {seg.text} + {:else} + {seg.text} + {/if} + {/each} +

    + {/if} + + + + + + {#if doc.tags && doc.tags.length > 0} +
    + {#each doc.tags as tag (tag.id)} + + {/each} +
    + {/if} + + +
    +
    + {doc.documentDate ? formatDate(doc.documentDate) : '—'} +
    +
    + + +
    +
    +
    + + + +
    +
    +
  • diff --git a/frontend/src/lib/components/DocumentRow.svelte.spec.ts b/frontend/src/lib/components/DocumentRow.svelte.spec.ts new file mode 100644 index 00000000..a5b335e4 --- /dev/null +++ b/frontend/src/lib/components/DocumentRow.svelte.spec.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentRow from './DocumentRow.svelte'; +import type { components } from '$lib/generated/api'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +afterEach(() => cleanup()); + +type DocumentSearchItem = components['schemas']['DocumentSearchItem']; + +function makeItem(overrides: Partial = {}): DocumentSearchItem { + return { + document: { + id: '1', + title: 'Testbrief', + originalFilename: 'testbrief.pdf', + status: 'UPLOADED', + documentDate: '2024-03-15', + sender: null, + receivers: [], + tags: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + metadataComplete: false, + scriptType: 'UNKNOWN' + }, + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + completionPercentage: 0, + contributors: [], + ...overrides + }; +} + +// ─── Title ──────────────────────────────────────────────────────────────────── + +describe('DocumentRow – title', () => { + it('renders document title', async () => { + render(DocumentRow, { item: makeItem() }); + await expect.element(page.getByRole('heading', { name: 'Testbrief' })).toBeInTheDocument(); + }); + + it('falls back to originalFilename when title is null', async () => { + const item = makeItem({ document: { ...makeItem().document, title: null } }); + render(DocumentRow, { item }); + await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument(); + }); + + it('renders a mark element for highlighted title offsets', async () => { + const item = makeItem({ + document: { ...makeItem().document, title: 'Brief an Anna' }, + matchData: { + titleOffsets: [{ start: 0, length: 5 }], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + } + }); + render(DocumentRow, { item }); + const mark = page.getByRole('mark'); + await expect.element(mark).toBeInTheDocument(); + await expect.element(mark).toHaveTextContent('Brief'); + }); +}); + +// ─── Snippet ────────────────────────────────────────────────────────────────── + +describe('DocumentRow – snippet', () => { + it('shows transcription snippet when present', async () => { + const item = makeItem({ + matchData: { + transcriptionSnippet: 'Er schrieb einen langen Brief', + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + } + }); + render(DocumentRow, { item }); + await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument(); + }); + + it('does not render snippet section when no snippet', async () => { + render(DocumentRow, { item: makeItem() }); + await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument(); + }); +}); + +// ─── Sender / receivers ─────────────────────────────────────────────────────── + +describe('DocumentRow – sender', () => { + it('shows sender display name', async () => { + const item = makeItem({ + document: { + ...makeItem().document, + sender: { id: 's1', displayName: 'Großmutter Maria' } + } + }); + render(DocumentRow, { item }); + await expect.element(page.getByText('Großmutter Maria').first()).toBeInTheDocument(); + }); + + it('shows unknown fallback when sender is null', async () => { + render(DocumentRow, { item: makeItem() }); + const unknownElements = page.getByText('Unbekannt'); + await expect.element(unknownElements.first()).toBeInTheDocument(); + }); +}); + +// ─── Tags ───────────────────────────────────────────────────────────────────── + +describe('DocumentRow – tags', () => { + it('renders tag buttons', async () => { + const item = makeItem({ + document: { + ...makeItem().document, + tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }] + } + }); + render(DocumentRow, { item }); + await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument(); + }); + + it('navigates to /documents?tag=… on tag click', async () => { + const { goto } = await import('$app/navigation'); + const item = makeItem({ + document: { + ...makeItem().document, + tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }] + } + }); + render(DocumentRow, { item }); + await page.getByRole('button', { name: 'Urlaub & Reise' }).click(); + expect(goto).toHaveBeenCalledWith('/documents?tag=Urlaub%20%26%20Reise'); + }); + + it('tag click does not navigate to the document detail page', async () => { + const item = makeItem({ + document: { + ...makeItem().document, + tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }] + } + }); + render(DocumentRow, { item }); + const before = window.location.href; + await page.getByRole('button', { name: 'Familie' }).click(); + expect(window.location.href).toBe(before); + }); +}); + +// ─── ProgressRing & ContributorStack ───────────────────────────────────────── + +describe('DocumentRow – progress ring and contributors', () => { + it('renders the completion percentage label', async () => { + const item = makeItem({ completionPercentage: 42 }); + render(DocumentRow, { item }); + await expect.element(page.getByText('42%').first()).toBeInTheDocument(); + }); + + it('renders contributor initials when contributors present', async () => { + const item = makeItem({ + contributors: [{ initials: 'AR', color: '#4a90e2', name: 'Anna Raddatz' }] + }); + render(DocumentRow, { item }); + await expect.element(page.getByText('AR').first()).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/ProgressRing.svelte b/frontend/src/lib/components/ProgressRing.svelte new file mode 100644 index 00000000..389a3b9a --- /dev/null +++ b/frontend/src/lib/components/ProgressRing.svelte @@ -0,0 +1,26 @@ + + +
    + + + {percentage}% + +
    diff --git a/frontend/src/lib/components/ProgressRing.svelte.spec.ts b/frontend/src/lib/components/ProgressRing.svelte.spec.ts new file mode 100644 index 00000000..8efc36e5 --- /dev/null +++ b/frontend/src/lib/components/ProgressRing.svelte.spec.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ProgressRing from './ProgressRing.svelte'; + +afterEach(cleanup); + +describe('ProgressRing', () => { + it('renders the correct stroke-dasharray for 75%', async () => { + render(ProgressRing, { percentage: 75 }); + const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null; + expect(arc).not.toBeNull(); + // circumference = 2 * π * 7 ≈ 43.98; 75% of that ≈ 32.99 + const dasharray = arc!.getAttribute('stroke-dasharray') ?? ''; + const filled = parseFloat(dasharray.split(' ')[0]); + expect(filled).toBeCloseTo(32.99, 1); + }); + + it('renders a gray label when percentage is 0', async () => { + render(ProgressRing, { percentage: 0 }); + const label = page.getByText('0%'); + await expect.element(label).toBeInTheDocument(); + // Label should carry the gray class, not the mint class + const el = (await label.element()) as HTMLElement; + expect(el.className).toContain('text-gray-400'); + }); + + it('renders a mint-colored label when percentage is > 0', async () => { + render(ProgressRing, { percentage: 75 }); + const label = page.getByText('75%'); + await expect.element(label).toBeInTheDocument(); + const el = (await label.element()) as HTMLElement; + expect(el.className).toContain('text-accent'); + }); + + it('renders a fully filled arc for 100%', async () => { + render(ProgressRing, { percentage: 100 }); + const arc = document.querySelector('circle.fill-arc') as SVGCircleElement | null; + expect(arc).not.toBeNull(); + const dasharray = arc!.getAttribute('stroke-dasharray') ?? ''; + const filled = parseFloat(dasharray.split(' ')[0]); + expect(filled).toBeCloseTo(43.98, 1); + }); +}); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index f3ac663b..aa4e5cd1 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 */ diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 16a89e66..c89e3ec2 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -3,85 +3,41 @@ import { createApiClient } from '$lib/api.server'; import type { components } from '$lib/generated/api'; type StatsDTO = components['schemas']['StatsDTO']; -type Document = components['schemas']['Document']; -type SearchMatchData = components['schemas']['SearchMatchData']; type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO']; type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO']; type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; -export async function load({ url, fetch }) { - const q = url.searchParams.get('q') || ''; - const from = url.searchParams.get('from') || ''; - const to = url.searchParams.get('to') || ''; - const senderId = url.searchParams.get('senderId') || ''; - const receiverId = url.searchParams.get('receiverId') || ''; - const tags = url.searchParams.getAll('tag'); - const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE'] as const; - type ValidSort = (typeof VALID_SORTS)[number]; - const rawSort = url.searchParams.get('sort') ?? 'DATE'; - const sort: ValidSort = (VALID_SORTS as readonly string[]).includes(rawSort) - ? (rawSort as ValidSort) - : 'DATE'; - const VALID_DIRS = ['asc', 'desc'] as const; - type ValidDir = (typeof VALID_DIRS)[number]; - const rawDir = url.searchParams.get('dir') ?? 'desc'; - const dir: ValidDir = (VALID_DIRS as readonly string[]).includes(rawDir) - ? (rawDir as ValidDir) - : 'desc'; - const tagQ = url.searchParams.get('tagQ') || ''; - const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND'; - - const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ; - +export async function load({ fetch }) { const api = createApiClient(fetch); try { - const [docsResult, personsResult] = await Promise.all([ - isDashboard - ? Promise.resolve(null) - : api.GET('/api/documents/search', { - params: { - query: { - q: q || undefined, - from: from || undefined, - to: to || undefined, - senderId: senderId || undefined, - receiverId: receiverId || undefined, - tag: tags.length ? tags : undefined, - tagQ: tagQ && !tags.length ? tagQ : undefined, - tagOp: tagOp === 'OR' ? 'OR' : undefined, - sort, - dir: dir || undefined - } - } - }), - api.GET('/api/persons') - ]); + const personsResult = await api.GET('/api/persons'); if (personsResult.response.status === 401) { throw redirect(302, '/login'); } - if (docsResult && docsResult.response.status === 401) { - throw redirect(302, '/login'); - } - const searchResult = docsResult?.data as { - documents?: Document[]; - total?: number; - matchData?: Record; - } | null; - const documents: Document[] = searchResult?.documents ?? []; - const total: number = searchResult?.total ?? 0; - const matchData: Record = searchResult?.matchData ?? {}; - const allPersons = (personsResult.data ?? []) as { - id: string; - firstName: string; - lastName: string; - }[]; - const senderObj = allPersons.find((p) => p.id === senderId); - const receiverObj = allPersons.find((p) => p.id === receiverId); + const [ + statsResult, + resumeResult, + pulseResult, + activityResult, + segmentationResult, + transcriptionResult, + readyResult, + weeklyStatsResult + ] = await Promise.allSettled([ + api.GET('/api/stats'), + api.GET('/api/dashboard/resume'), + api.GET('/api/dashboard/pulse'), + api.GET('/api/dashboard/activity', { params: { query: { limit: 7 } } }), + api.GET('/api/transcription/segmentation-queue'), + api.GET('/api/transcription/transcription-queue'), + api.GET('/api/transcription/ready-to-read'), + api.GET('/api/transcription/weekly-stats') + ]); let stats: StatsDTO | null = null; let resumeDoc: DashboardResumeDTO | null = null; @@ -92,58 +48,32 @@ export async function load({ url, fetch }) { let readyDocs: TranscriptionQueueItemDTO[] = []; let weeklyStats: TranscriptionWeeklyStatsDTO | null = null; - if (isDashboard) { - const [ - statsResult, - resumeResult, - pulseResult, - activityResult, - segmentationResult, - transcriptionResult, - readyResult, - weeklyStatsResult - ] = await Promise.allSettled([ - api.GET('/api/stats'), - api.GET('/api/dashboard/resume'), - api.GET('/api/dashboard/pulse'), - api.GET('/api/dashboard/activity', { params: { query: { limit: 7 } } }), - api.GET('/api/transcription/segmentation-queue'), - api.GET('/api/transcription/transcription-queue'), - api.GET('/api/transcription/ready-to-read'), - api.GET('/api/transcription/weekly-stats') - ]); - - if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) { - stats = statsResult.value.data ?? null; - } - if (resumeResult.status === 'fulfilled' && resumeResult.value.response.ok) { - resumeDoc = (resumeResult.value.data as DashboardResumeDTO) ?? null; - } - if (pulseResult.status === 'fulfilled' && pulseResult.value.response.ok) { - pulse = (pulseResult.value.data as DashboardPulseDTO) ?? null; - } - if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) { - activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? []; - } - if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) { - segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[]; - } - if (transcriptionResult.status === 'fulfilled' && transcriptionResult.value.response.ok) { - transcriptionDocs = (transcriptionResult.value.data ?? []) as TranscriptionQueueItemDTO[]; - } - if (readyResult.status === 'fulfilled' && readyResult.value.response.ok) { - readyDocs = (readyResult.value.data ?? []) as TranscriptionQueueItemDTO[]; - } - if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) { - weeklyStats = weeklyStatsResult.value.data ?? null; - } + if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) { + stats = statsResult.value.data ?? null; + } + if (resumeResult.status === 'fulfilled' && resumeResult.value.response.ok) { + resumeDoc = (resumeResult.value.data as DashboardResumeDTO) ?? null; + } + if (pulseResult.status === 'fulfilled' && pulseResult.value.response.ok) { + pulse = (pulseResult.value.data as DashboardPulseDTO) ?? null; + } + if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) { + activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? []; + } + if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) { + segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[]; + } + if (transcriptionResult.status === 'fulfilled' && transcriptionResult.value.response.ok) { + transcriptionDocs = (transcriptionResult.value.data ?? []) as TranscriptionQueueItemDTO[]; + } + if (readyResult.status === 'fulfilled' && readyResult.value.response.ok) { + readyDocs = (readyResult.value.data ?? []) as TranscriptionQueueItemDTO[]; + } + if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) { + weeklyStats = weeklyStatsResult.value.data ?? null; } return { - isDashboard, - documents, - total, - matchData, stats, resumeDoc, pulse, @@ -152,21 +82,12 @@ export async function load({ url, fetch }) { transcriptionDocs, readyDocs, weeklyStats, - initialValues: { - senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}`.trim() : '', - receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}`.trim() : '' - }, - filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp }, error: null as string | null }; } catch (e) { if ((e as { status?: number }).status) throw e; console.error('Error loading data:', e); return { - isDashboard, - documents: [], - total: 0, - matchData: {} as Record, stats: null, resumeDoc: null, pulse: null, @@ -175,8 +96,6 @@ export async function load({ url, fetch }) { transcriptionDocs: [], readyDocs: [], weeklyStats: null, - initialValues: { senderName: '', receiverName: '' }, - filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp }, error: 'Daten konnten nicht geladen werden.' as string | null }; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index fa88e079..300313ce 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,94 +1,13 @@ @@ -71,205 +59,35 @@ const showDividers = $derived(groupedDocuments.length >= 2);

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

    {/if} - -
    - {#if error} + +{#if error} +
    {error}
    - {:else if documents.length > 0} - {#each groupedDocuments as group (group.label)} - {#if showDividers} - - {/if} -
    +{:else if items.length > 0} + + {#each yearGroups as group (group.year)} +
    +
    + {group.year} +
    +
      + {#each group.items as item (item.document.id)} + {/each}
    - {/each} - {:else} - +
    + {/each} +{:else} + +
    = 2); {q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()}

    - {/if} -
    +
    +{/if} diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index bb6e61da..f90882fa 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -2,64 +2,63 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import DocumentList from './DocumentList.svelte'; +import type { components } from '$lib/generated/api'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); afterEach(() => cleanup()); -const baseProps = { - documents: [], - canWrite: false, - error: null, - total: 0, - q: '', - matchData: {} as Record< - string, - import('$lib/generated/api').components['schemas']['SearchMatchData'] - > -}; +type DocumentSearchItem = components['schemas']['DocumentSearchItem']; -type DocOverrides = { - id?: string; - title?: string; - documentDate?: string | null; - sender?: { id?: string; firstName?: string | null; lastName: string; displayName: string } | null; - receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[]; - tags?: { id: string; name: string }[]; -}; +function makeItem(overrides: Partial = {}): DocumentSearchItem { + return { + document: { + id: '1', + title: 'Testbrief', + originalFilename: 'testbrief.pdf', + status: 'UPLOADED', + documentDate: '2024-03-15', + sender: null, + receivers: [], + tags: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + metadataComplete: false, + scriptType: 'UNKNOWN' + }, + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + completionPercentage: 0, + contributors: [], + ...overrides + }; +} -const makeDoc = (overrides: DocOverrides = {}) => ({ - id: '1', - title: 'Testbrief', - originalFilename: 'testbrief.pdf', - status: 'UPLOADED' as const, - documentDate: '2024-03-15', - location: null, - sender: null, - receivers: [] as { - id?: string; - firstName?: string | null; - lastName: string; - displayName: string; - }[], - tags: [], - ...overrides -}); +const baseProps = { items: [], canWrite: false, error: null, total: 0, q: '' }; + +// ─── Result count ───────────────────────────────────────────────────────────── describe('DocumentList – result count', () => { it('shows result count when total > 0', async () => { - render(DocumentList, { ...baseProps, documents: [makeDoc()], total: 1, q: 'test' }); + render(DocumentList, { ...baseProps, items: [makeItem()], total: 1, q: 'test' }); await expect.element(page.getByText('1 Dokumente')).toBeInTheDocument(); }); - it('does not show result count when total is 0 and there is no error', async () => { + it('does not show result count when total is 0', async () => { render(DocumentList, { ...baseProps, total: 0, q: '' }); - const count = page.getByText(/\d+ Dokumente/); - await expect.element(count).not.toBeInTheDocument(); + await expect.element(page.getByText(/\d+ Dokumente/)).not.toBeInTheDocument(); }); }); -describe('DocumentList – empty state with search term', () => { +// ─── Empty state ────────────────────────────────────────────────────────────── + +describe('DocumentList – empty state', () => { it('shows generic empty heading when q is empty', async () => { render(DocumentList, { ...baseProps }); await expect.element(page.getByText(/Keine Dokumente/)).toBeInTheDocument(); @@ -71,73 +70,49 @@ describe('DocumentList – empty state with search term', () => { }); }); -// ─── Group headers ──────────────────────────────────────────────────────────── +// ─── Year grouping ──────────────────────────────────────────────────────────── -describe('DocumentList – group headers', () => { - it('renders group-divider elements when DATE sort spans multiple years', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1923-04-12' }), - makeDoc({ id: '2', documentDate: '1965-08-03' }) +describe('DocumentList – year grouping', () => { + it('groups documents by year into separate cards', async () => { + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }), + makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } }) ]; - render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' }); - await expect.element(page.getByTestId('group-divider').first()).toBeInTheDocument(); + render(DocumentList, { ...baseProps, items, total: 2 }); + const yearCards = page.getByTestId('year-card'); + await expect.element(yearCards.first()).toBeInTheDocument(); + await expect.element(yearCards.nth(1)).toBeInTheDocument(); }); - it('does not render group-divider when DATE sort has only one distinct year', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1938-01-01' }), - makeDoc({ id: '2', documentDate: '1938-06-15' }) + it('uses Ohne Datum for items with no documentDate', async () => { + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } }) ]; - render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' }); - await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument(); + render(DocumentList, { ...baseProps, items, total: 1 }); + await expect.element(page.getByText('Ohne Datum')).toBeInTheDocument(); }); - it('does not render group-divider for TITLE sort', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1923-04-12' }), - makeDoc({ id: '2', documentDate: '1965-08-03' }) + it('single year renders one year-card', async () => { + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }), + makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } }) ]; - render(DocumentList, { ...baseProps, documents, total: 2, sort: 'TITLE' }); - await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument(); - }); - - it('shows Undatiert fallback label when sort is undefined and doc has no date', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1938-01-01' }), - makeDoc({ id: '2', documentDate: null }) - ]; - render(DocumentList, { ...baseProps, documents, total: 2 }); // sort omitted — defaults to DATE grouping - await expect.element(page.getByText(/UNDATIERT/i)).toBeInTheDocument(); - }); - - it('a doc with two receivers appears in both receiver groups', async () => { - const documents = [ - makeDoc({ - id: '1', - receivers: [ - { firstName: null, lastName: 'Müller', displayName: 'Anna Müller' }, - { firstName: null, lastName: 'Bauer', displayName: 'Karl Bauer' } - ] - }) - ]; - render(DocumentList, { ...baseProps, documents, total: 1, sort: 'RECEIVER' }); - const links = page.getByRole('link', { name: /Testbrief/ }); - await expect.element(links.first()).toBeInTheDocument(); - await expect.element(links.nth(1)).toBeInTheDocument(); + render(DocumentList, { ...baseProps, items, total: 2 }); + const yearCards = page.getByTestId('year-card'); + // Only one card for 1938 + await expect.element(yearCards.first()).toBeInTheDocument(); + await expect.element(yearCards.nth(1)).not.toBeInTheDocument(); }); }); -// ─── Match data: snippet and title highlighting ─────────────────────────────── +// ─── DocumentRow rendering (delegated) ─────────────────────────────────────── -describe('DocumentList – match snippets and highlights', () => { - it('shows transcription snippet when matchData has one for the document', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { +describe('DocumentList – DocumentRow delegation', () => { + it('shows transcription snippet when matchData has one', async () => { + const items = [ + makeItem({ + document: { ...makeItem().document, id: 'doc1' }, + matchData: { transcriptionSnippet: 'Er schrieb einen langen Brief', titleOffsets: [], senderMatched: false, @@ -146,26 +121,23 @@ describe('DocumentList – match snippets and highlights', () => { snippetOffsets: [], summaryOffsets: [] } - } - }); + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1 }); await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument(); }); - it('does not show snippet section when matchData has no entry for the document', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { ...baseProps, documents: [doc], total: 1, matchData: {} }); + it('does not render snippet when matchData has no transcription snippet', async () => { + const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })]; + render(DocumentList, { ...baseProps, items, total: 1 }); await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument(); }); - it('renders a element when titleOffsets are present', async () => { - const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, + it('renders mark for title highlight when titleOffsets present', async () => { + const items = [ + makeItem({ + document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' }, + matchData: { titleOffsets: [{ start: 0, length: 5 }], // "Brief" senderMatched: false, matchedReceiverIds: [], @@ -173,221 +145,11 @@ describe('DocumentList – match snippets and highlights', () => { snippetOffsets: [], summaryOffsets: [] } - } - }); - // The word "Brief" should be inside a element + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1 }); const mark = page.getByRole('mark'); await expect.element(mark).toBeInTheDocument(); await expect.element(mark).toHaveTextContent('Brief'); }); - - it('renders title as plain text when titleOffsets is empty', async () => { - const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - await expect.element(page.getByRole('mark')).not.toBeInTheDocument(); - await expect.element(page.getByText('Brief an Anna')).toBeInTheDocument(); - }); - - it('renders inside snippet when snippetOffsets are present', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: 'Er schrieb einen Brief', - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [{ start: 17, length: 5 }], // "Brief" - summaryOffsets: [] - } - } - }); - const snippet = page.getByTestId('search-snippet'); - await expect.element(snippet).toBeInTheDocument(); - const mark = snippet.getByRole('mark'); - await expect.element(mark).toBeInTheDocument(); - await expect.element(mark).toHaveTextContent('Brief'); - }); - - it('renders snippet as plain text when snippetOffsets is empty', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: 'Er schrieb einen Brief', - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - const snippet = page.getByTestId('search-snippet'); - await expect.element(snippet).toBeInTheDocument(); - // No mark elements inside the snippet when offsets is empty - await expect.element(snippet.getByRole('mark')).not.toBeInTheDocument(); - }); - - it('visually marks sender when senderMatched is true', async () => { - const doc = makeDoc({ - id: 'doc1', - sender: { - id: 'sender-1', - firstName: 'Walter', - lastName: 'Raddatz', - displayName: 'Walter Raddatz' - } - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: true, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - const senderMark = page.getByTestId('sender-match'); - await expect.element(senderMark).toBeInTheDocument(); - await expect.element(senderMark).toHaveTextContent('Walter Raddatz'); - }); - - it('does not mark sender when senderMatched is false', async () => { - const doc = makeDoc({ - id: 'doc1', - sender: { - id: 'sender-1', - firstName: 'Walter', - lastName: 'Raddatz', - displayName: 'Walter Raddatz' - } - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - await expect.element(page.getByTestId('sender-match')).not.toBeInTheDocument(); - }); - - it('visually marks matched receiver when their id is in matchedReceiverIds', async () => { - const doc = makeDoc({ - id: 'doc1', - receivers: [ - { id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }, - { id: 'p-2', firstName: 'Karl', lastName: 'Bauer', displayName: 'Karl Bauer' } - ] - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: ['p-1'], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - // Only Anna Schmidt should be marked - const receiverMark = page.getByTestId('receiver-match'); - await expect.element(receiverMark).toBeInTheDocument(); - await expect.element(receiverMark).toHaveTextContent('Anna Schmidt'); - }); - - it('renders a color dot on tag chips that have a color', async () => { - const doc = makeDoc({ - id: 'doc1', - tags: [{ id: 'tag-1', name: 'Familie', color: 'sage' }] - }); - render(DocumentList, { ...baseProps, documents: [doc], total: 1 }); - const dot = page.getByTestId('tag-color-dot'); - await expect.element(dot).toBeInTheDocument(); - await expect.element(dot).toHaveAttribute('data-color', 'sage'); - }); - - it('does not render a color dot on tag chips without a color', async () => { - const doc = makeDoc({ - id: 'doc1', - tags: [{ id: 'tag-1', name: 'Familie' }] - }); - render(DocumentList, { ...baseProps, documents: [doc], total: 1 }); - await expect.element(page.getByTestId('tag-color-dot')).not.toBeInTheDocument(); - }); - - it('visually marks matched tag when its id is in matchedTagIds', async () => { - const doc = makeDoc({ - id: 'doc1', - tags: [ - { id: 'tag-1', name: 'Familiengeschichte' }, - { id: 'tag-2', name: 'Reise' } - ] - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: ['tag-1'], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - const tagMark = page.getByTestId('tag-match'); - await expect.element(tagMark).toBeInTheDocument(); - await expect.element(tagMark).toHaveTextContent('Familiengeschichte'); - }); }); diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index 396e7c78..eb4c6b8e 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -125,7 +125,7 @@ $effect(() => { diff --git a/frontend/src/routes/documents/+page.server.ts b/frontend/src/routes/documents/+page.server.ts new file mode 100644 index 00000000..ed46e46a --- /dev/null +++ b/frontend/src/routes/documents/+page.server.ts @@ -0,0 +1,93 @@ +import { redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; +import type { components } from '$lib/generated/api'; + +type DocumentSearchItem = components['schemas']['DocumentSearchItem']; + +const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const; +type ValidSort = (typeof VALID_SORTS)[number]; +const VALID_DIRS = ['asc', 'desc'] as const; +type ValidDir = (typeof VALID_DIRS)[number]; + +export async function load({ url, fetch }) { + const q = url.searchParams.get('q') || ''; + const from = url.searchParams.get('from') || ''; + const to = url.searchParams.get('to') || ''; + const senderId = url.searchParams.get('senderId') || ''; + const receiverId = url.searchParams.get('receiverId') || ''; + const tags = url.searchParams.getAll('tag'); + const rawSort = url.searchParams.get('sort') ?? 'DATE'; + const sort: ValidSort = (VALID_SORTS as readonly string[]).includes(rawSort) + ? (rawSort as ValidSort) + : 'DATE'; + const rawDir = url.searchParams.get('dir') ?? 'desc'; + const dir: ValidDir = (VALID_DIRS as readonly string[]).includes(rawDir) + ? (rawDir as ValidDir) + : 'desc'; + const tagQ = url.searchParams.get('tagQ') || ''; + const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND'; + + const api = createApiClient(fetch); + + let result; + try { + result = await api.GET('/api/documents/search', { + params: { + query: { + q: q || undefined, + from: from || undefined, + to: to || undefined, + senderId: senderId || undefined, + receiverId: receiverId || undefined, + tag: tags.length ? tags : undefined, + tagQ: tagQ && !tags.length ? tagQ : undefined, + tagOp: tagOp === 'OR' ? 'OR' : undefined, + sort, + dir: dir || undefined + } + } + }); + } catch { + return { + items: [] as DocumentSearchItem[], + total: 0, + q, + from, + to, + senderId, + receiverId, + tags, + sort, + dir, + tagQ, + tagOp, + error: 'Daten konnten nicht geladen werden.' as string | null + }; + } + + if (result.response.status === 401) { + throw redirect(302, '/login'); + } + + const errorMessage: string | null = !result.response.ok + ? (getErrorMessage((result.error as unknown as { code?: string })?.code) ?? + 'Daten konnten nicht geladen werden.') + : null; + + return { + items: (result.data?.items ?? []) as DocumentSearchItem[], + total: result.data?.total ?? 0, + q, + from, + to, + senderId, + receiverId, + tags, + sort, + dir, + tagQ, + tagOp, + error: errorMessage + }; +} diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte new file mode 100644 index 00000000..588fc998 --- /dev/null +++ b/frontend/src/routes/documents/+page.svelte @@ -0,0 +1,123 @@ + + + + {m.nav_documents()} – Familienarchiv + + +
    +

    {m.nav_documents()}

    + + (qFocused = true)} + onblur={() => (qFocused = false)} + /> + + +
    diff --git a/frontend/src/routes/documents/page.server.spec.ts b/frontend/src/routes/documents/page.server.spec.ts new file mode 100644 index 00000000..d345023e --- /dev/null +++ b/frontend/src/routes/documents/page.server.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +function makeUrl(params: Record = {}) { + const url = new URL('http://localhost/documents'); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + value.forEach((v) => url.searchParams.append(key, v)); + } else { + url.searchParams.set(key, value); + } + } + return url; +} + +// ─── search params forwarding ───────────────────────────────────────────────── + +describe('documents page load — search params', () => { + it('passes q, from, to to the search API', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet).toHaveBeenCalledWith( + '/api/documents/search', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }) + }) + }) + ); + }); + + it('passes senderId and receiverId to the search API', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet).toHaveBeenCalledWith( + '/api/documents/search', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ senderId: 'p-1', receiverId: 'p-2' }) + }) + }) + ); + }); + + it('passes sort, dir, tagQ to the search API', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(mockGet).toHaveBeenCalledWith( + '/api/documents/search', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }) + }) + }) + ); + }); + + it('returns items and total from the search result', async () => { + const item = { + document: { id: 'd1' }, + matchData: {}, + completionPercentage: 0, + contributors: [] + }; + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [item], total: 42 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ q: 'test' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(42); + }); + + it('returns filter values in the result for pre-filling the UI', async () => { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], total: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + expect(result.q).toBe('Urlaub'); + expect(result.from).toBe('1920-01-01'); + expect(result.sort).toBe('TITLE'); + expect(result.dir).toBe('asc'); + }); +}); + +// ─── 401 redirect ───────────────────────────────────────────────────────────── + +describe('documents page load — auth redirect', () => { + it('redirects to /login when search API returns 401', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 401 }, data: null }) + } as ReturnType); + + await expect( + load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + ).rejects.toMatchObject({ location: '/login' }); + }); +}); + +// ─── network error fallback ─────────────────────────────────────────────────── + +describe('documents page load — network error fallback', () => { + it('returns error string instead of throwing when API call throws', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockRejectedValue(new Error('Network failure')) + } as ReturnType); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.error).toBeTruthy(); + expect(result.items).toEqual([]); + }); +}); diff --git a/frontend/src/routes/documents/page.svelte.spec.ts b/frontend/src/routes/documents/page.svelte.spec.ts new file mode 100644 index 00000000..59af31bf --- /dev/null +++ b/frontend/src/routes/documents/page.svelte.spec.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ navigating: { to: null } })); + +import Page from './+page.svelte'; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const SEARCH_LABEL = 'Titel, Personen, Tags durchsuchen…'; + +function makeData(overrides: Record = {}) { + return { + items: [], + total: 0, + q: '', + from: '', + to: '', + senderId: '', + receiverId: '', + tags: [], + sort: 'DATE', + dir: 'desc', + tagQ: '', + tagOp: 'AND', + canWrite: false, + error: null, + ...overrides + }; +} + +// ─── Initial state from server data ─────────────────────────────────────────── + +describe('documents page — initial state', () => { + it('pre-fills the search input from data.q', async () => { + render(Page, { data: makeData({ q: 'Geburtstag' }) }); + await expect + .element(page.getByRole('textbox', { name: SEARCH_LABEL })) + .toHaveValue('Geburtstag'); + }); + + it('leaves the search input empty when data.q is not set', async () => { + render(Page, { data: makeData() }); + await expect.element(page.getByRole('textbox', { name: SEARCH_LABEL })).toHaveValue(''); + }); +}); + +// ─── URL building via triggerSearch ─────────────────────────────────────────── + +describe('documents page — URL building', () => { + beforeEach(() => vi.useFakeTimers()); + + it('calls goto with /documents?q=… after the 500 ms debounce', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill('Urlaub'); + + expect(goto).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + + expect(goto).toHaveBeenCalledOnce(); + const [url] = vi.mocked(goto).mock.calls[0]; + expect(url).toContain('q=Urlaub'); + expect(url).toMatch(/^\/documents\?/); + }); + + it('omits q from the URL when the search field is empty', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill(''); + + vi.advanceTimersByTime(500); + + const [url] = vi.mocked(goto).mock.calls[0] ?? ['']; + expect(url).not.toContain('q='); + }); + + it('second keystroke within 500 ms cancels the first timer — goto called only once', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill('U'); + vi.advanceTimersByTime(200); + await input.fill('Urlaub'); + vi.advanceTimersByTime(500); + + expect(goto).toHaveBeenCalledOnce(); + }); + + it('passes keepFocus and noScroll options to goto', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { data: makeData() }); + const input = page.getByRole('textbox', { name: SEARCH_LABEL }); + await input.fill('Brief'); + vi.advanceTimersByTime(500); + + expect(goto).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ keepFocus: true, noScroll: true }) + ); + }); +}); diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index aa4c6ba9..0f93fa13 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -81,6 +81,9 @@ /* ─── 4. Light mode (default) ─────────────────────────────────────────────── */ :root { + /* 1px accent bar + 64px nav = 65px total sticky header height */ + --header-height: 65px; + --c-canvas: #f0efe9; --c-surface: #ffffff; --c-overlay: #ffffff; diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index a4b47e06..68791aff 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -19,10 +19,42 @@ function makeUrl(params: Record = {}) { return url; } -// ─── dashboard mode (no search filters) ────────────────────────────────────── +// ─── always-dashboard behaviour ─────────────────────────────────────────────── -describe('home page load — dashboard mode', () => { - it('sets isDashboard true and fetches stats, resume, pulse, and activity APIs', async () => { +it('never calls /api/documents/search regardless of URL params', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), + fetch: vi.fn() as unknown as typeof fetch + }); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); + expect(calledEndpoints).not.toContain('/api/documents/search'); +}); + +it('always fetches dashboard data regardless of URL params', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl({ q: 'Urlaub' }), fetch: vi.fn() as unknown as typeof fetch }); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); + expect(calledEndpoints).toContain('/api/stats'); + expect(calledEndpoints).toContain('/api/dashboard/resume'); + expect(calledEndpoints).toContain('/api/dashboard/pulse'); + expect(calledEndpoints).toContain('/api/dashboard/activity'); +}); + +// ─── dashboard mode ──────────────────────────────────────────────────────────── + +describe('home page load — dashboard', () => { + it('fetches stats, resume, pulse, and activity APIs', async () => { const mockGet = vi .fn() .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons @@ -67,13 +99,11 @@ describe('home page load — dashboard mode', () => { const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); - 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([]); }); it('returns stats with totalDocuments from /api/stats', async () => { @@ -109,8 +139,8 @@ describe('home page load — dashboard mode', () => { .fn() .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons .mockRejectedValueOnce(new Error('network')) // stats - .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete - .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // resume + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // pulse vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient >); @@ -158,123 +188,6 @@ describe('home page load — dashboard mode', () => { }); }); -// ─── search mode (any filter active) ───────────────────────────────────────── - -describe('home page load — search mode', () => { - it('sets isDashboard false and skips widget APIs when q is set', async () => { - const mockGet = vi - .fn() - .mockResolvedValueOnce({ - response: { ok: true, status: 200 }, - data: { documents: [{ id: 'd1' }], total: 1 } - }) // search docs - .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons - vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< - typeof createApiClient - >); - - const result = await load({ - url: makeUrl({ q: 'Urlaub' }), - fetch: vi.fn() as unknown as typeof fetch - }); - - expect(result.isDashboard).toBe(false); - expect(result.documents).toHaveLength(1); - expect(result.stats).toBeNull(); - expect(result.resumeDoc).toBeNull(); - expect(result.activityFeed).toEqual([]); - // Only two API calls — no widget calls - expect(mockGet).toHaveBeenCalledTimes(2); - }); - - it('passes search params from the URL to the documents API', async () => { - const mockGet = vi - .fn() - .mockResolvedValueOnce({ - response: { ok: true, status: 200 }, - data: { documents: [], total: 0 } - }) - .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); - vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< - typeof createApiClient - >); - - await load({ - url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), - fetch: vi.fn() as unknown as typeof fetch - }); - - const firstCall = mockGet.mock.calls[0]; - expect(firstCall[1].params.query.q).toBe('Urlaub'); - expect(firstCall[1].params.query.from).toBe('2020-01-01'); - }); - - it('sets isDashboard false when only tagQ is set', async () => { - const mockGet = vi - .fn() - .mockResolvedValueOnce({ - response: { ok: true, status: 200 }, - data: { documents: [{ id: 'd1' }], total: 1 } - }) // search docs - .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons - vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< - typeof createApiClient - >); - - const result = await load({ - url: makeUrl({ tagQ: 'fam' }), - fetch: vi.fn() as unknown as typeof fetch - }); - - expect(result.isDashboard).toBe(false); - expect(result.documents).toHaveLength(1); - }); -}); - -it('passes sort, dir, and tagQ params to the documents API', async () => { - const mockGet = vi - .fn() - .mockResolvedValueOnce({ - response: { ok: true, status: 200 }, - data: { documents: [], total: 0 } - }) - .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); - vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< - typeof createApiClient - >); - - await load({ - url: makeUrl({ q: 'test', sort: 'TITLE', dir: 'asc', tagQ: 'fam' }), - fetch: vi.fn() as unknown as typeof fetch - }); - - const firstCall = mockGet.mock.calls[0]; - expect(firstCall[1].params.query.sort).toBe('TITLE'); - expect(firstCall[1].params.query.dir).toBe('asc'); - expect(firstCall[1].params.query.tagQ).toBe('fam'); -}); - -it('returns total from the DocumentSearchResult envelope', async () => { - const mockGet = vi - .fn() - .mockResolvedValueOnce({ - response: { ok: true, status: 200 }, - data: { documents: [{ id: 'd1' }], total: 42 } - }) - .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); - vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< - typeof createApiClient - >); - - const result = await load({ - url: makeUrl({ q: 'test' }), - fetch: vi.fn() as unknown as typeof fetch - }); - - expect(result.documents).toHaveLength(1); - expect(result.total).toBe(42); -}); - // ─── 401 redirect ───────────────────────────────────────────────────────────── describe('home page load — auth redirect', () => { @@ -300,6 +213,5 @@ describe('home page load — network error fallback', () => { const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); expect(result.error).toBe('Daten konnten nicht geladen werden.'); - expect(result.documents).toEqual([]); }); }); diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index d705edb3..4fbd2b92 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -1,50 +1,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import type { components } from '$lib/generated/api'; import Page from './+page.svelte'; -const tick = () => new Promise((r) => setTimeout(r, 0)); - -vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); - -// Silence fetch calls from PersonTypeahead when advanced filters are open -vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) -); +type User = components['schemas']['AppUser']; afterEach(cleanup); -// ─── Test data ──────────────────────────────────────────────────────────────── +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); -const emptyData = { - user: undefined, +const baseData = { + user: { + id: 'u1', + email: 'max@example.com', + firstName: 'Max', + lastName: '', + groups: [], + enabled: true, + createdAt: '2024-01-01T00:00:00Z' + } as User, canWrite: true, canAnnotate: false, - isDashboard: false, - filters: { - q: '', - from: '', - to: '', - senderId: '', - receiverId: '', - tags: [], - sort: 'DATE' as const, - dir: 'desc' as const, - tagQ: '', - tagOp: 'AND' - }, - documents: [], - total: 0, - matchData: {} as Record< - string, - import('$lib/generated/api').components['schemas']['SearchMatchData'] - >, resumeDoc: null, pulse: null, activityFeed: [], stats: null, - initialValues: { senderName: '', receiverName: '' }, segmentationDocs: [], transcriptionDocs: [], readyDocs: [], @@ -52,194 +33,22 @@ const emptyData = { error: null }; -const makeDoc = (overrides = {}) => ({ - id: '1', - title: 'Testbrief', - originalFilename: 'testbrief.pdf', - status: 'UPLOADED' as const, - documentDate: '2024-03-15', - location: 'Berlin', - sender: { - id: 'p1', - firstName: 'Max', - lastName: 'Mustermann', - displayName: 'Max Mustermann', - personType: 'PERSON' as const - }, - receivers: [ - { - id: 'p2', - firstName: 'Anna', - lastName: 'Musterfrau', - displayName: 'Anna Musterfrau', - personType: 'PERSON' as const - } - ], - tags: [{ id: 't1', name: 'Familie' }], - filePath: '/files/testbrief.pdf', - createdAt: '2024-03-15T10:00:00Z', - updatedAt: '2024-03-15T10:00:00Z', - ...overrides -}); +// ─── Dashboard layout ───────────────────────────────────────────────────────── -const dataWithDocs = { ...emptyData, documents: [makeDoc()] }; - -// ─── Search bar ─────────────────────────────────────────────────────────────── - -describe('Home page – search bar', () => { - it('renders the full-text search input', async () => { - render(Page, { data: emptyData }); - await expect - .element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026')) - .toBeInTheDocument(); - await page.screenshot({ path: 'test-results/screenshots/home-default.png' }); +describe('Home page – dashboard layout', () => { + it('does not render a search input', async () => { + render(Page, { data: baseData }); + const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…'); + await expect.element(input).not.toBeInTheDocument(); }); - it('renders the filter toggle button', async () => { - render(Page, { data: emptyData }); - // Use exact match to avoid collision with the empty-state "Alle Filter löschen" button - await expect - .element(page.getByRole('button', { name: 'Filter', exact: true })) - .toBeInTheDocument(); + it('renders a greeting for the logged-in user', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByRole('heading', { level: 1 })).toBeInTheDocument(); }); - it('renders the reset link pointing to /', async () => { - render(Page, { data: emptyData }); - const resetLink = page.getByTitle('Filter zurücksetzen'); - await expect.element(resetLink).toBeInTheDocument(); - await expect.element(resetLink).toHaveAttribute('href', '/'); - }); - - it('pre-fills the search input from filters.q', async () => { - render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } }); - await expect - .element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026')) - .toHaveValue('Urlaub'); - }); -}); - -// ─── Advanced filters ───────────────────────────────────────────────────────── - -describe('Home page – advanced filters', () => { - it('hides the advanced filters by default', async () => { - render(Page, { data: emptyData }); - // Date inputs are inside the {#if showAdvanced} block → not in DOM - await tick(); - expect(document.querySelector('input[id="from"]')).toBeNull(); - expect(document.querySelector('input[id="to"]')).toBeNull(); - }); - - it('toggles the advanced filter panel open on button click', async () => { - render(Page, { data: emptyData }); - await page.getByRole('button', { name: 'Filter', exact: true }).click(); - await tick(); - expect(document.querySelector('input[id="from"]')).not.toBeNull(); - expect(document.querySelector('input[id="to"]')).not.toBeNull(); - await page.screenshot({ path: 'test-results/screenshots/home-filters-open.png' }); - }); - - it('collapses the advanced filter panel on second click', async () => { - render(Page, { data: emptyData }); - const btn = page.getByRole('button', { name: 'Filter', exact: true }); - await btn.click(); - // Wait for the input to appear before clicking again - await expect.element(page.getByText('Schlagworte')).toBeInTheDocument(); - await btn.click(); - // Wait for slide transition to finish - await expect.element(page.getByText('Schlagworte')).not.toBeInTheDocument(); - }); - - it('renders the tag filter section when filters are open', async () => { - render(Page, { data: emptyData }); - await page.getByRole('button', { name: 'Filter', exact: true }).click(); - await expect.element(page.getByText('Schlagworte')).toBeInTheDocument(); - }); -}); - -// ─── Document list ──────────────────────────────────────────────────────────── - -describe('Home page – document list', () => { - it('shows empty state when there are no documents', async () => { - render(Page, { data: emptyData }); - await expect.element(page.getByText('Keine Dokumente gefunden')).toBeInTheDocument(); - await page.screenshot({ path: 'test-results/screenshots/home-empty-state.png' }); - }); - - it('renders a document with title, date, location, sender and receiver', async () => { - render(Page, { data: dataWithDocs }); - await expect.element(page.getByText('Testbrief')).toBeInTheDocument(); - await expect.element(page.getByText('15. März 2024')).toBeInTheDocument(); - await expect.element(page.getByText('Berlin')).toBeInTheDocument(); - await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); - await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument(); - await page.screenshot({ path: 'test-results/screenshots/home-with-documents.png' }); - }); - - it('renders a tag chip for each document tag', async () => { - render(Page, { data: dataWithDocs }); - await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument(); - }); - - it('renders "Unbekannt" for sender when sender is null', async () => { - const data = { ...emptyData, documents: [makeDoc({ sender: null })] }; - render(Page, { data }); - await expect.element(page.getByText('Unbekannt')).toBeInTheDocument(); - }); - - it('renders original filename when title is empty', async () => { - const data = { ...emptyData, documents: [makeDoc({ title: null })] }; - render(Page, { data }); - await expect.element(page.getByText('testbrief.pdf')).toBeInTheDocument(); - }); - - it('links each document to its detail page', async () => { - render(Page, { data: dataWithDocs }); - const link = page.getByRole('link', { name: /Testbrief/ }); - await expect.element(link).toHaveAttribute('href', '/documents/1'); - }); - - it('renders the "Neues Dokument" link', async () => { - render(Page, { data: emptyData }); - const link = page.getByRole('link', { name: /Neues Dokument/i }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute('href', '/documents/new'); - }); -}); - -// ─── Keystroke preservation (issue #34) ────────────────────────────────────── - -describe('Home page – search input keystroke preservation', () => { - it('does not overwrite the search input while the user is focused and stale data arrives', async () => { - const { rerender } = render(Page, { data: emptyData }); - - const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'); - - // User types "abc" — input is focused - await input.click(); - await input.fill('abc'); - - // Simulate a navigation completing with stale data (q='a') while the user is still typing - await rerender({ data: { ...emptyData, filters: { ...emptyData.filters, q: 'a' } } }); - await tick(); - - // Input must still show what the user typed, not the stale URL value - await expect.element(input).toHaveValue('abc'); - }); -}); - -// ─── Dashboard mode ─────────────────────────────────────────────────────────── - -describe('Home page – dashboard mode', () => { - const dashboardData = { - ...emptyData, - isDashboard: true, - resumeDoc: null, - pulse: null, - activityFeed: [] - }; - - it('renders empty state when resumeDoc is null', async () => { - render(Page, { data: dashboardData }); + it('renders resume strip empty state when resumeDoc is null', async () => { + render(Page, { data: baseData }); const empty = page.getByTestId('resume-strip-empty'); await expect.element(empty).toBeInTheDocument(); }); @@ -250,58 +59,22 @@ describe('Home page – dashboard mode', () => { title: 'Geburtsurkunde', caption: 'Max · 1920', excerpt: 'Hiermit…', - page: 1, - pages: 3, + totalBlocks: 3, pct: 33, collaborators: [] }; - render(Page, { data: { ...dashboardData, resumeDoc: resume } }); + render(Page, { data: { ...baseData, resumeDoc: resume } }); const strip = page.getByTestId('resume-strip'); await expect.element(strip).toBeInTheDocument(); }); -}); -// ─── Error state ────────────────────────────────────────────────────────────── - -describe('Home page – error state', () => { - it('shows the error message when data.error is set', async () => { - const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' }; - render(Page, { data }); - await expect.element(page.getByText('Daten konnten nicht geladen werden.')).toBeInTheDocument(); - await page.screenshot({ path: 'test-results/screenshots/home-error.png' }); - }); -}); - -// ─── Loading spinner ────────────────────────────────────────────────────────── - -describe('Home page – loading spinner', () => { - it('does not show spinner by default', async () => { - render(Page, { data: emptyData }); - const spinner = page.getByRole('status'); - await expect.element(spinner).not.toBeInTheDocument(); - }); -}); - -// ─── Sort controls ──────────────────────────────────────────────────────────── - -describe('Home page – sort controls', () => { - it('pre-fills sort from filters.sort', async () => { - const data = { - ...emptyData, - filters: { ...emptyData.filters, sort: 'TITLE', dir: 'asc', tagQ: '' } - }; - render(Page, { data }); - const select = page.getByRole('combobox'); - await expect.element(select).toHaveValue('TITLE'); + it('shows drop zone when canWrite is true', async () => { + render(Page, { data: { ...baseData, canWrite: true } }); + await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).toBeInTheDocument(); }); - it('renders direction toggle with asc indicator when dir is asc', async () => { - const data = { - ...emptyData, - filters: { ...emptyData.filters, sort: 'DATE', dir: 'asc', tagQ: '' } - }; - render(Page, { data }); - const btn = page.getByRole('button', { name: /aufsteigend/i }); - await expect.element(btn).toBeInTheDocument(); + it('hides drop zone when canWrite is false', async () => { + render(Page, { data: { ...baseData, canWrite: false } }); + await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).not.toBeInTheDocument(); }); });