feat: dedicated /documents search & browse page #282

Merged
marcel merged 50 commits from feat/issue-281-documents-page into main 2026-04-20 09:11:08 +02:00
36 changed files with 1642 additions and 1300 deletions

View File

@@ -106,4 +106,38 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY a.document_id, MIN(a.happened_at)
""", nativeQuery = true)
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> 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<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
}

View File

@@ -38,7 +38,15 @@ public class AuditLogQueryService {
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
}
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())

View File

@@ -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<ActivityActorDTO> contributors
) {}

View File

@@ -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<Document> documents,
List<DocumentSearchItem> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long total,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Map<UUID, SearchMatchData> 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<Document> documents, Map<UUID, SearchMatchData> 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<Document> documents) {
return withMatchData(documents, Map.of());
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
return new DocumentSearchResult(items, items.size());
}
}

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.repository;
import java.util.UUID;
public interface CompletionStatsRow {
UUID getDocumentId();
int getCompletionPercentage();
}

View File

@@ -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<TranscriptionBlock, UUID> {
@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<CompletionStatsRow> findCompletionStatsForDocuments(
@Param("documentIds") Collection<UUID> documentIds);
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);

View File

@@ -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<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
return buildResult(sortByFirstReceiver(results, dir), text);
}
if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec);
List<Document> 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<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
return buildResult(results, text);
}
private DocumentSearchResult buildResult(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
List<DocumentSearchItem> 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<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
return transcriptionBlockQueryService.getCompletionStats(docIds);
}
private Sort resolveSort(DocumentSort sort, String dir) {

View File

@@ -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<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
Map<UUID, Integer> result = new HashMap<>();
for (CompletionStatsRow row : blockRepository.findCompletionStatsForDocuments(documentIds)) {
result.put(row.getDocumentId(), row.getCompletionPercentage());
}
return result;
}
}

View File

@@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_reviewed
ON transcription_blocks (document_id, reviewed);

View File

@@ -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"));
}

View File

@@ -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<ContributorRow> 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<ContributorRow> 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<ContributorRow> 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<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).isEmpty();
}
}

View File

@@ -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<Document> docs = List.of(doc(id));
Map<UUID, SearchMatchData> 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);
}

View File

@@ -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<CompletionStatsRow> 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<CompletionStatsRow> 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<CompletionStatsRow> 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<CompletionStatsRow> 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<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A, DOC_B));
Map<UUID, Integer> byDoc = rows.stream()
.collect(Collectors.toMap(CompletionStatsRow::getDocumentId, CompletionStatsRow::getCompletionPercentage));
assertThat(byDoc).containsEntry(DOC_A, 100);
assertThat(byDoc).containsEntry(DOC_B, 0);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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';
}
</script>
{#if safeContributors.length === 0}
<span
role="img"
aria-label="Noch niemand angefangen"
class="inline-block h-[22px] w-[22px] flex-shrink-0 rounded-full border-[1.5px] border-dashed border-[#cdcbbf]"
title="Noch niemand angefangen"
></span>
{:else}
<span class="inline-flex items-center">
@@ -24,8 +29,8 @@ const safeContributors = $derived(contributors ?? []);
<span
role="img"
aria-label={actor.name ?? actor.initials}
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
style="background-color: {actor.color || '#8c9aa3'};"
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
style="background-color: {safeColor(actor.color)};"
title={actor.name ?? actor.initials}
>
{actor.initials}
@@ -33,7 +38,9 @@ const safeContributors = $derived(contributors ?? []);
{/each}
{#if hasMore}
<span
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[9px] font-bold text-ink-3 ring-2 ring-white"
role="img"
aria-label="Weitere Mitwirkende"
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[10px] font-bold text-ink-3 ring-2 ring-white"
>
</span>

View File

@@ -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();
});
});

View File

@@ -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';
}
</script>
{#if resumeDoc === null}
@@ -94,7 +98,7 @@ const { resumeDoc }: Props = $props();
{#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)}
<span
class="-ml-1 inline-flex h-6 w-6 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white"
style="background:{collab.color}">{collab.initials}</span
style="background:{safeColor(collab.color)}">{collab.initials}</span
>
{/each}
</div>

View File

@@ -0,0 +1,182 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { components } from '$lib/generated/api';
import { applyOffsets } from '$lib/search';
import { formatDate } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages.js';
import ProgressRing from './ProgressRing.svelte';
import ContributorStack from './ContributorStack.svelte';
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
let { item }: { item: DocumentSearchItem } = $props();
const doc = $derived(item.document);
const titleText = $derived(doc.title || doc.originalFilename);
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));
const snippet = $derived(item.matchData?.transcriptionSnippet ?? null);
const snippetSegments = $derived(
snippet ? applyOffsets(snippet, item.matchData?.snippetOffsets ?? []) : null
);
const senderMatched = $derived(item.matchData?.senderMatched ?? false);
const matchedReceiverIds = $derived(new Set(item.matchData?.matchedReceiverIds ?? []));
const matchedTagIds = $derived(new Set(item.matchData?.matchedTagIds ?? []));
const hasMore = $derived(item.contributors.length >= 4);
function tagClass(matched: boolean): string {
return matched
? 'inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-primary text-primary-fg transition-colors'
: 'inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs font-bold tracking-widest uppercase bg-muted text-ink hover:bg-primary hover:text-primary-fg transition-colors';
}
function safeTagColor(color: string | null | undefined): string {
return color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#cdcbbf';
}
</script>
<li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block px-4 py-4 sm:py-5">
<div class="flex gap-0 sm:gap-5">
<!-- Left column -->
<div class="flex-1 sm:border-r sm:border-line sm:pr-5">
<!-- Title -->
<h3 class="mb-1 font-serif text-xl font-medium text-ink group-hover:underline">
{#each titleSegments as seg, i (i)}
{#if seg.highlight}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>
{:else}
{seg.text}
{/if}
{/each}
</h3>
<!-- Snippet -->
{#if snippetSegments}
<p
data-testid="search-snippet"
class="mb-2 line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each snippetSegments as seg, i (i)}
{#if seg.highlight}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>
{:else}
{seg.text}
{/if}
{/each}
</p>
{/if}
<!-- Sender / receivers — desktop only -->
<div class="mt-2 mb-2 hidden gap-4 font-sans text-xs text-ink-2 sm:grid sm:grid-cols-2">
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
<span class="ml-1">
{#if doc.sender}
{#if senderMatched}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{doc.sender.displayName}</mark
>
{:else}
{doc.sender.displayName}
{/if}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
<span class="ml-1">
{#if doc.receivers && doc.receivers.length > 0}
{#each doc.receivers as receiver, i (receiver.id)}
{#if i > 0}<span>, </span>{/if}
{#if matchedReceiverIds.has(receiver.id)}
<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{receiver.displayName}</mark
>
{:else}
{receiver.displayName}
{/if}
{/each}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
</div>
<!-- Tags -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each doc.tags as tag (tag.id)}
<button
type="button"
class={tagClass(matchedTagIds.has(tag.id))}
onclick={(e) => { e.stopPropagation(); goto('/documents?tag=' + encodeURIComponent(tag.name)); }}
>
{#if tag.color}
<span
class="h-1.5 w-1.5 rounded-full"
style="background-color: {safeTagColor(tag.color)};"
></span>
{/if}
{tag.name}
</button>
{/each}
</div>
{/if}
<!-- Mobile-only metadata -->
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
<div>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
<div class="flex items-center gap-2">
<ProgressRing percentage={item.completionPercentage} />
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
</div>
</div>
</div>
<!-- Right column — desktop only -->
<div class="hidden flex-col gap-2 pl-4 font-sans text-xs text-ink-2 sm:flex sm:w-44 lg:w-56">
<div>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>
<span class="ml-1">
{#if doc.sender}
{doc.sender.displayName}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_to()}</span>
<span class="ml-1">
{#if doc.receivers && doc.receivers.length > 0}
{doc.receivers.map((r) => r.displayName).join(', ')}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</span>
</div>
<div class="flex items-center gap-2">
<ProgressRing percentage={item.completionPercentage} />
<ContributorStack contributors={item.contributors} hasMore={hasMore} />
</div>
</div>
</div>
</a>
</li>

View File

@@ -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> = {}): 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();
});
});

View File

@@ -0,0 +1,26 @@
<script lang="ts">
let { percentage }: { percentage: number } = $props();
</script>
<div class="flex flex-col items-center">
<svg width="36" height="36" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="7" fill="none" stroke="var(--c-line)" stroke-width="2" />
<circle
class="fill-arc"
cx="10"
cy="10"
r="7"
fill="none"
stroke="var(--c-accent)"
stroke-width="2"
stroke-linecap="round"
transform="rotate(-90 10 10)"
stroke-dasharray="{(percentage / 100) * 43.98} 43.98"
/>
</svg>
<span
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
>
{percentage}%
</span>
</div>

View File

@@ -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);
});
});

View File

@@ -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 */

View File

@@ -3,96 +3,22 @@ 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<string, SearchMatchData>;
} | null;
const documents: Document[] = searchResult?.documents ?? [];
const total: number = searchResult?.total ?? 0;
const matchData: Record<string, SearchMatchData> = 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);
let stats: StatsDTO | null = null;
let resumeDoc: DashboardResumeDTO | null = null;
let pulse: DashboardPulseDTO | null = null;
let activityFeed: ActivityFeedItemDTO[] = [];
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
let readyDocs: TranscriptionQueueItemDTO[] = [];
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
if (isDashboard) {
const [
statsResult,
resumeResult,
@@ -113,6 +39,15 @@ export async function load({ url, fetch }) {
api.GET('/api/transcription/weekly-stats')
]);
let stats: StatsDTO | null = null;
let resumeDoc: DashboardResumeDTO | null = null;
let pulse: DashboardPulseDTO | null = null;
let activityFeed: ActivityFeedItemDTO[] = [];
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
let readyDocs: TranscriptionQueueItemDTO[] = [];
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
stats = statsResult.value.data ?? null;
}
@@ -137,13 +72,8 @@ export async function load({ url, fetch }) {
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<string, SearchMatchData>,
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
};
}

View File

@@ -1,94 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { navigating } from '$app/state';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from './SearchFilterBar.svelte';
import DropZone from './DropZone.svelte';
import DocumentList from './DocumentList.svelte';
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte';
import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte';
import * as m from '$lib/paraglide/messages.js';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
let q = $state(untrack(() => data.filters?.q || ''));
let qFocused = $state(false);
let from = $state(untrack(() => data.filters?.from || ''));
let to = $state(untrack(() => data.filters?.to || ''));
let senderId = $state(untrack(() => data.filters?.senderId || ''));
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
untrack(() => (data.filters?.tags || []).map((name: string) => ({ name })))
);
let sort = $state(untrack(() => data.filters?.sort || 'DATE'));
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
let tagQ = $state(untrack(() => data.filters?.tagQ || ''));
let tagOperator = $state<'AND' | 'OR'>(
untrack(() => (data.filters?.tagOp as 'AND' | 'OR') || 'AND')
);
const hasAdvancedFilters = (filters: typeof data.filters) =>
(filters?.tags?.length ?? 0) > 0 ||
!!filters?.senderId ||
!!filters?.receiverId ||
!!filters?.from ||
!!filters?.to;
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
let searchTimer: ReturnType<typeof setTimeout>;
function triggerSearch() {
const params = new SvelteURLSearchParams();
if (q) params.set('q', q);
if (from) params.set('from', from);
if (to) params.set('to', to);
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag.name));
if (sort) params.set('sort', sort);
if (dir) params.set('dir', dir);
if (tagQ) params.set('tagQ', tagQ);
if (tagOperator === 'OR') params.set('tagOp', 'OR');
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
function handleTextSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => triggerSearch(), 500);
}
function handleImmediateSearch() {
clearTimeout(searchTimer);
triggerSearch();
}
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
$effect(() => {
const cur = tagNames.map((t) => t.name).join(',');
if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearch();
}
});
$effect(() => {
if (!qFocused) q = data.filters?.q || '';
from = data.filters?.from || '';
to = data.filters?.to || '';
senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || '';
tagNames = (data.filters?.tags || []).map((name: string) => ({ name }));
sort = data.filters?.sort || 'DATE';
dir = data.filters?.dir || 'desc';
tagQ = data.filters?.tagQ || '';
tagOperator = (data.filters?.tagOp as 'AND' | 'OR') || 'AND';
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
});
const greetingText = $derived.by(() => {
const name = data?.user?.firstName ?? '';
const h = new Date().getHours();
@@ -103,28 +22,6 @@ const greetingText = $derived.by(() => {
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<SearchFilterBar
bind:q={q}
bind:from={from}
bind:to={to}
bind:senderId={senderId}
bind:receiverId={receiverId}
bind:tagNames={tagNames}
bind:showAdvanced={showAdvanced}
bind:sort={sort}
bind:dir={dir}
bind:tagQ={tagQ}
bind:tagOperator={tagOperator}
initialSenderName={data.initialValues?.senderName}
initialReceiverName={data.initialValues?.receiverName}
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
/>
{#if data.isDashboard}
{#if data?.user}
<div class="mb-6">
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
@@ -156,15 +53,4 @@ const greetingText = $derived.by(() => {
{/if}
</div>
</div>
{:else}
<DocumentList
documents={data.documents ?? []}
canWrite={data.canWrite}
error={data.error}
total={data.total ?? 0}
q={q}
sort={sort}
matchData={data.matchData ?? {}}
/>
{/if}
</main>

View File

@@ -40,9 +40,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
<!-- Desktop nav -->
<nav class="hidden items-stretch lg:flex lg:space-x-1">
<a
href="/"
href="/documents"
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
{page.url.pathname.startsWith('/documents')
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
@@ -141,9 +141,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
<div class="relative border-b border-line bg-surface shadow-md">
<nav id="mobile-nav">
<a
href="/"
href="/documents"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
{page.url.pathname.startsWith('/documents')
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>

View File

@@ -1,51 +1,39 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { groupDocuments } from '$lib/utils/groupDocuments';
import GroupDivider from '$lib/components/GroupDivider.svelte';
import { applyOffsets } from '$lib/search';
import DocumentRow from '$lib/components/DocumentRow.svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
let {
documents,
items,
canWrite,
error,
total = 0,
q = '',
sort,
matchData = {}
q = ''
}: {
documents: {
id: string;
title?: string | null;
originalFilename: string;
documentDate?: string | null;
location?: 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; color?: string | null }[];
}[];
items: DocumentSearchItem[];
canWrite: boolean;
error?: string | null;
total?: number;
q?: string;
sort?: string;
matchData?: Record<string, components['schemas']['SearchMatchData']>;
} = $props();
const fallbackLabel = $derived(
(sort ?? 'DATE') === 'DATE' ? m.docs_group_undated() : m.docs_group_unknown()
);
const groupedDocuments = $derived.by(() =>
groupDocuments(documents, sort ?? 'DATE', fallbackLabel)
);
const showDividers = $derived(groupedDocuments.length >= 2);
const yearGroups = $derived.by(() => {
const map = new SvelteMap<string, DocumentSearchItem[]>();
for (const item of items) {
const year = item.document.documentDate?.substring(0, 4) ?? 'Ohne Datum';
const group = map.get(year);
if (group) {
group.push(item);
} else {
map.set(year, [item]);
}
}
return Array.from(map.entries()).map(([year, groupItems]) => ({ year, items: groupItems }));
});
</script>
<!-- DOCUMENT LIST HEADER -->
@@ -71,205 +59,35 @@ const showDividers = $derived(groupedDocuments.length >= 2);
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
{/if}
<!-- DOCUMENT LIST -->
<div class="border border-line bg-surface shadow-sm">
{#if error}
<!-- ERROR -->
{#if error}
<div class="border border-line bg-surface shadow-sm">
<div class="bg-red-50 p-8 text-center text-red-600">
{error}
</div>
{:else if documents.length > 0}
{#each groupedDocuments as group (group.label)}
{#if showDividers}
<GroupDivider label={group.label} />
{/if}
<ul class="divide-y divide-line-2">
{#each group.documents as doc (doc.id)}
{@const titleText = doc.title || doc.originalFilename}
{@const match = matchData?.[doc.id]}
{@const titleOffsets = match?.titleOffsets ?? []}
{@const titleSegments = applyOffsets(titleText, titleOffsets)}
{@const snippet = match?.transcriptionSnippet}
{@const snippetSegments = snippet ? applyOffsets(snippet, match?.snippetOffsets ?? []) : null}
{@const summary = match?.summarySnippet}
{@const summarySegments = summary ? applyOffsets(summary, match?.summaryOffsets ?? []) : null}
{@const senderMatched = match?.senderMatched ?? false}
{@const matchedReceiverIds = new Set(match?.matchedReceiverIds ?? [])}
{@const matchedTagIds = new Set(match?.matchedTagIds ?? [])}
<li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row">
<!-- Main Info -->
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
{#each titleSegments as seg, i (i)}
{#if seg.highlight}<mark
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}
{/each}
</h3>
</div>
<!-- Metadata Row -->
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-ink-2">
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
{#if doc.location}
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.location}
</div>
{/if}
</div>
{#if snippetSegments}
<div class="mb-4 flex items-baseline gap-2">
<span
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_content()}</span
>
<p
data-testid="search-snippet"
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each snippetSegments as seg, i (i)}{#if seg.highlight}<mark
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}{/each}
</p>
</div>
{/if}
{#if summarySegments}
<div class="mb-4 flex items-baseline gap-2">
<span
class="shrink-0 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_summary()}</span
>
<p
data-testid="search-summary"
class="line-clamp-2 font-sans text-sm text-ink-2 italic"
>
{#each summarySegments as seg, i (i)}{#if seg.highlight}<mark
class="bg-transparent text-inherit not-italic underline decoration-brand-navy decoration-2 underline-offset-2"
>{seg.text}</mark
>{:else}{seg.text}{/if}{/each}
</p>
</div>
{/if}
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_from()}</span
>
{#if doc.sender}
{#if senderMatched}
<mark
data-testid="sender-match"
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{doc.sender.displayName}</mark
>
{:else}
<span class="text-ink">{doc.sender.displayName}</span>
{/if}
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_to()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-ink">
{#each doc.receivers as receiver, ri (receiver.id ?? ri)}
{#if ri > 0}<span>, </span>{/if}
{#if receiver.id && matchedReceiverIds.has(receiver.id)}
<mark
data-testid="receiver-match"
class="bg-transparent text-inherit underline decoration-brand-navy decoration-2 underline-offset-2"
>{receiver.displayName}</mark
>
{:else}
{receiver.displayName}
{/if}
{/each}
</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
</div>
<!-- Tags Display -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-2 pt-3">
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[10px] font-bold tracking-widest uppercase transition-colors hover:bg-primary hover:text-primary-fg {matchedTagIds.has(tag.id) ? 'bg-muted text-ink underline decoration-brand-navy decoration-2 underline-offset-2' : 'bg-muted text-ink'}"
title={tag.name}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
goto(`/?tag=${encodeURIComponent(tag.name)}`);
}}
>
{#if tag.color}
<span
data-testid="tag-color-dot"
data-color={tag.color}
style="background-color: var(--c-tag-{tag.color})"
class="inline-block h-2 w-2 flex-shrink-0 rounded-full"
></span>
{/if}
{#if matchedTagIds.has(tag.id)}
<span data-testid="tag-match">{tag.name}</span>
{:else}
{tag.name}
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Arrow Icon -->
{:else if items.length > 0}
<!-- YEAR CARDS -->
{#each yearGroups as group (group.year)}
<div
class="hidden items-center text-ink-3 transition-colors group-hover:text-accent sm:flex"
data-testid="year-card"
class="mb-4 overflow-hidden border border-line bg-surface shadow-sm"
>
<div class="border-b border-line bg-muted px-5 py-2">
<span class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{group.year}</span
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
</div>
</a>
</li>
<ul class="divide-y divide-line">
{#each group.items as item (item.document.id)}
<DocumentRow item={item} />
{/each}
</ul>
</div>
{/each}
{:else}
<!-- Empty State -->
{:else}
<!-- EMPTY STATE -->
<div class="border border-line bg-surface shadow-sm">
<div class="p-16 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<img
@@ -284,11 +102,11 @@ const showDividers = $derived(groupedDocuments.length >= 2);
{q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()}
</p>
<button
onclick={() => goto('/')}
onclick={() => goto('/documents')}
class="mt-6 text-sm font-bold tracking-wide text-primary uppercase transition hover:text-ink-2"
>
{m.docs_empty_btn_clear()}
</button>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -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 }[];
};
const makeDoc = (overrides: DocOverrides = {}) => ({
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
return {
document: {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED' as const,
status: 'UPLOADED',
documentDate: '2024-03-15',
location: null,
sender: null,
receivers: [] as {
id?: string;
firstName?: string | null;
lastName: string;
displayName: string;
}[],
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 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,
describe('DocumentList DocumentRow delegation', () => {
it('shows transcription snippet when matchData has one', async () => {
const items = [
makeItem({
document: { ...makeItem().document, id: 'doc1' },
matchData: {
doc1: {
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 <mark> element when titleOffsets are present', async () => {
const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' });
render(DocumentList, {
...baseProps,
documents: [doc],
total: 1,
it('renders mark for title highlight when titleOffsets present', async () => {
const items = [
makeItem({
document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' },
matchData: {
doc1: {
transcriptionSnippet: undefined,
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 <mark> 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 <mark> 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');
});
});

View File

@@ -125,7 +125,7 @@ $effect(() => {
<!-- Reset Button -->
<a
href="/"
href="/documents"
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-ink-3 transition hover:text-red-500"
title={m.docs_btn_reset_title()}
>

View File

@@ -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
};
}

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { navigating } from '$app/state';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from '../SearchFilterBar.svelte';
import DocumentList from '../DocumentList.svelte';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
// Local state initialised from server-returned filter values.
// untrack() prevents infinite reactive loops during initialisation.
let q = $state(untrack(() => data.q || ''));
let qFocused = $state(false);
let from = $state(untrack(() => data.from || ''));
let to = $state(untrack(() => data.to || ''));
let senderId = $state(untrack(() => data.senderId || ''));
let receiverId = $state(untrack(() => data.receiverId || ''));
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
untrack(() => (data.tags || []).map((name: string) => ({ name })))
);
let sort = $state(untrack(() => data.sort || 'DATE'));
let dir = $state(untrack(() => data.dir || 'desc'));
let tagQ = $state(untrack(() => data.tagQ || ''));
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
function hasAdvancedFilters() {
return (
(data.tags?.length ?? 0) > 0 || !!data.senderId || !!data.receiverId || !!data.from || !!data.to
);
}
let showAdvanced = $state(untrack(hasAdvancedFilters));
let searchTimer: ReturnType<typeof setTimeout>;
function triggerSearch() {
const params = new SvelteURLSearchParams();
if (q) params.set('q', q);
if (from) params.set('from', from);
if (to) params.set('to', to);
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
tagNames.forEach((tag) => params.append('tag', tag.name));
if (sort) params.set('sort', sort);
if (dir) params.set('dir', dir);
if (tagQ) params.set('tagQ', tagQ);
if (tagOperator === 'OR') params.set('tagOp', 'OR');
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
}
function handleTextSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => triggerSearch(), 500);
}
function handleImmediateSearch() {
clearTimeout(searchTimer);
triggerSearch();
}
// Trigger search reactively when the tag list changes.
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
$effect(() => {
const cur = tagNames.map((t) => t.name).join(',');
if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearch();
}
});
// Keep local filter state in sync with server data after navigation completes.
// Guard q: skip overwrite while the user is actively typing.
$effect(() => {
if (!qFocused) q = data.q || '';
from = data.from || '';
to = data.to || '';
senderId = data.senderId || '';
receiverId = data.receiverId || '';
tagNames = (data.tags || []).map((name: string) => ({ name }));
sort = data.sort || 'DATE';
dir = data.dir || 'desc';
tagQ = data.tagQ || '';
tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND';
if (hasAdvancedFilters()) showAdvanced = true;
});
</script>
<svelte:head>
<title>{m.nav_documents()} Familienarchiv</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<h1 class="sr-only">{m.nav_documents()}</h1>
<SearchFilterBar
bind:q={q}
bind:from={from}
bind:to={to}
bind:senderId={senderId}
bind:receiverId={receiverId}
bind:tagNames={tagNames}
bind:showAdvanced={showAdvanced}
bind:sort={sort}
bind:dir={dir}
bind:tagQ={tagQ}
bind:tagOperator={tagOperator}
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onSearchImmediate={handleImmediateSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
/>
<DocumentList
items={data.items}
total={data.total}
q={data.q}
canWrite={data.canWrite}
error={data.error}
/>
</main>

View File

@@ -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<string, string | string[]> = {}) {
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<typeof createApiClient>);
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<typeof createApiClient>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.error).toBeTruthy();
expect(result.items).toEqual([]);
});
});

View File

@@ -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<string, unknown> = {}) {
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 })
);
});
});

View File

@@ -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;

View File

@@ -19,10 +19,42 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
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([]);
});
});

View File

@@ -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();
});
});