diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DensityFilters.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DensityFilters.java new file mode 100644 index 00000000..aa3e7f80 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DensityFilters.java @@ -0,0 +1,23 @@ +package org.raddatz.familienarchiv.document; + +import org.raddatz.familienarchiv.tag.TagOperator; + +import java.util.List; +import java.util.UUID; + +/** + * The non-date filters honoured by {@link DocumentService#getDensity(DensityFilters)}. + * Date bounds (from/to) are deliberately excluded — see the service Javadoc for why. + * + * Kept as a record so the seven values are passed as one named bundle instead of a + * positional argument list where two UUIDs (sender vs. receiver) can be swapped by + * accident at the call site. + */ +public record DensityFilters( + String text, + UUID sender, + UUID receiver, + List tags, + String tagQ, + DocumentStatus status, + TagOperator tagOperator) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java index add2a9f4..f4bf72d3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document; import java.io.IOException; import java.time.LocalDate; import java.util.ArrayList; +import java.util.concurrent.TimeUnit; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService; import org.raddatz.familienarchiv.user.UserService; import org.springframework.data.domain.Sort; import org.springframework.security.core.Authentication; +import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -388,6 +390,23 @@ public class DocumentController { return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable)); } + @GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity density( + @RequestParam(required = false) String q, + @RequestParam(required = false) UUID senderId, + @RequestParam(required = false) UUID receiverId, + @RequestParam(required = false, name = "tag") List tags, + @RequestParam(required = false) String tagQ, + @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status, + @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) { + TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; + DocumentDensityResult result = documentService.getDensity( + new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator)); + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate()) + .body(result); + } + // --- TRAINING LABELS --- public record TrainingLabelRequest(String label, boolean enrolled) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java new file mode 100644 index 00000000..8e695589 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java @@ -0,0 +1,27 @@ +package org.raddatz.familienarchiv.document; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +/** + * Result of the timeline density aggregation. + * + *

{@code minDate} / {@code maxDate} are intentionally not marked + * {@code @Schema(requiredMode = REQUIRED)} — the empty-result case (no + * documents match the filter) returns them as {@code null}, which surfaces in + * the generated TypeScript as {@code minDate?: string | null}. Frontend code + * must treat them as optional. + */ +public record DocumentDensityResult( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List buckets, + LocalDate minDate, + LocalDate maxDate +) { + /** The "no documents match the filter" result, with no buckets and null date bounds. */ + public static DocumentDensityResult empty() { + return new DocumentDensityResult(List.of(), null, null); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 8cef03a5..cca184d8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -48,6 +48,7 @@ import java.io.IOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDate; +import java.time.YearMonth; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -125,6 +126,74 @@ public class DocumentService { return titles; } + /** + * Per-month document counts for the timeline density widget (issue #385). + * + *

Filter-reactive: the chart recomputes when other filters (sender, + * receiver, tag, q, status) change so it always matches the list it sits + * above. Date bounds (`from`/`to`) are deliberately omitted — the chart is + * the surface for picking those, so it must always span the broader space + * the user is selecting within. + * + *

Implementation note: groups in memory rather than via SQL GROUP BY + * because the existing {@link Specification} predicates compose easily + * with {@code findAll(spec)} and the archive size (≈5k docs) keeps this + * well under the 200ms p95 target. Cache-Control: max-age=300 on the + * controller layer absorbs repeated browse loads. + * + *

Tracked in issue #481 for re-evaluation when {@code documents > 50k} + * — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date, + * 'YYYY-MM')) and accept that the criteria/specification surface needs a + * parallel native-query path. + */ + public DocumentDensityResult getDensity(DensityFilters filters) { + List ftsIds = resolveFtsIds(filters.text()); + if (ftsIds != null && ftsIds.isEmpty()) { + return DocumentDensityResult.empty(); + } + List dates = loadFilteredDates(filters, ftsIds); + return aggregateByMonth(dates); + } + + /** + * Returns the FTS-ranked document IDs when {@code text} is non-blank, or {@code null} + * when no full-text query is active. An empty list means the FTS query ran but + * matched zero documents — the caller short-circuits on that signal. + */ + private List resolveFtsIds(String text) { + if (!StringUtils.hasText(text)) return null; + return documentRepository.findRankedIdsByFts(text); + } + + /** Loads matching documents and projects to non-null {@link LocalDate}s. */ + private List loadFilteredDates(DensityFilters filters, List ftsIds) { + boolean hasFts = ftsIds != null; + Specification spec = buildSearchSpec( + hasFts, ftsIds, null, null, + filters.sender(), filters.receiver(), + filters.tags(), filters.tagQ(), + filters.status(), filters.tagOperator()); + return documentRepository.findAll(spec).stream() + .map(Document::getDocumentDate) + .filter(Objects::nonNull) + .toList(); + } + + /** Buckets {@code dates} into one {@link MonthBucket} per YYYY-MM and computes min/max. */ + private DocumentDensityResult aggregateByMonth(List dates) { + if (dates.isEmpty()) return DocumentDensityResult.empty(); + Map counts = new java.util.TreeMap<>(); + for (LocalDate d : dates) { + counts.merge(YearMonth.from(d).toString(), 1, Integer::sum); + } + List buckets = counts.entrySet().stream() + .map(e -> new MonthBucket(e.getKey(), e.getValue())) + .toList(); + LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null); + LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null); + return new DocumentDensityResult(buckets, minDate, maxDate); + } + /** * Lädt eine Datei hoch. * - Prüft, ob ein Eintrag (aus Excel) schon existiert. diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/MonthBucket.java b/backend/src/main/java/org/raddatz/familienarchiv/document/MonthBucket.java new file mode 100644 index 00000000..eb41ccb4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/MonthBucket.java @@ -0,0 +1,10 @@ +package org.raddatz.familienarchiv.document; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MonthBucket( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "1915-08") + String month, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + int count +) {} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index ad07afb7..9d85491a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -44,6 +44,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -1240,4 +1241,100 @@ class DocumentControllerTest { .andExpect(jsonPath("$.errors[0].message").value( org.hamcrest.Matchers.containsString("not found"))); } + + // ─── GET /api/documents/density ─────────────────────────────────────────── + + @Test + void density_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/density")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void density_returns200_withResultBody_whenAuthenticated() throws Exception { + when(documentService.getDensity(any())).thenReturn( + new DocumentDensityResult( + List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)), + java.time.LocalDate.of(1915, 8, 3), + java.time.LocalDate.of(1915, 9, 1))); + + mockMvc.perform(get("/api/documents/density")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.buckets").isArray()) + .andExpect(jsonPath("$.buckets[0].month").value("1915-08")) + .andExpect(jsonPath("$.buckets[0].count").value(2)) + .andExpect(jsonPath("$.minDate").value("1915-08-03")) + .andExpect(jsonPath("$.maxDate").value("1915-09-01")); + } + + // Pins produces=APPLICATION_JSON_VALUE on the density mapping so the OpenAPI/TypeScript + // codegen records application/json instead of the wildcard. Without produces= the + // request-mapping accepts any Accept header and the OpenAPI emit falls back to the + // wildcard. Sending an Accept header that JSON cannot satisfy must NOT return 200 — + // Spring rejects with 406 (HttpMediaTypeNotAcceptableException), which our + // GlobalExceptionHandler may surface as 400. Either way it proves the route is + // locked to JSON. + @Test + @WithMockUser + void density_declaresApplicationJsonContentType() throws Exception { + when(documentService.getDensity(any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + + mockMvc.perform(get("/api/documents/density") + .accept(MediaType.APPLICATION_XML)) + .andExpect(status().is4xxClientError()); + } + + @Test + @WithMockUser + void density_emitsPrivateCacheControlHeader() throws Exception { + when(documentService.getDensity(any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + + mockMvc.perform(get("/api/documents/density")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("max-age=300"))) + .andExpect(header().string("Cache-Control", + org.hamcrest.Matchers.containsString("private"))); + } + + @Test + @WithMockUser + void density_forwardsSenderAndTagFilters() throws Exception { + when(documentService.getDensity(any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + UUID senderId = UUID.randomUUID(); + + mockMvc.perform(get("/api/documents/density") + .param("senderId", senderId.toString()) + .param("tag", "Familie") + .param("tag", "Urlaub") + .param("tagOp", "OR")) + .andExpect(status().isOk()); + + verify(documentService).getDensity(eq(new DensityFilters( + null, senderId, null, + List.of("Familie", "Urlaub"), + null, null, + org.raddatz.familienarchiv.tag.TagOperator.OR))); + } + + @Test + @WithMockUser + void density_forwardsStatusAndQueryText() throws Exception { + when(documentService.getDensity(any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + + mockMvc.perform(get("/api/documents/density") + .param("q", "Brief") + .param("status", "REVIEWED")) + .andExpect(status().isOk()); + + verify(documentService).getDensity(eq(new DensityFilters( + "Brief", null, null, null, null, + DocumentStatus.REVIEWED, + org.raddatz.familienarchiv.tag.TagOperator.AND))); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java new file mode 100644 index 00000000..3110a503 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java @@ -0,0 +1,162 @@ +package org.raddatz.familienarchiv.document; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.raddatz.familienarchiv.tag.Tag; +import org.raddatz.familienarchiv.tag.TagRepository; +import org.raddatz.familienarchiv.tag.TagOperator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test for the filter-reactive density aggregation. + * Density bars must recompute as the user changes other filters (sender, tag, + * status, …). The endpoint deliberately does NOT honour `from`/`to` — the chart + * is the surface for picking those, so it must always span the broader space + * the user is selecting within. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@Transactional +class DocumentDensityIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired DocumentService documentService; + @Autowired DocumentRepository documentRepository; + @Autowired PersonRepository personRepository; + @Autowired TagRepository tagRepository; + + private Person hans; + private Person anna; + private Tag familieTag; + private Tag urlaubTag; + + @BeforeEach + void seed() { + hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build()); + anna = personRepository.save(Person.builder().firstName("Anna").lastName("Weber").build()); + familieTag = tagRepository.save(Tag.builder().name("Familie").build()); + urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build()); + } + + private static DensityFilters noFilters() { + return new DensityFilters(null, null, null, null, null, null, null); + } + + @Test + void getDensity_returnsAllMonths_whenNoFiltersApplied() { + save("a", LocalDate.of(1915, 8, 3), null, Set.of()); + save("b", LocalDate.of(1915, 8, 17), null, Set.of()); + save("c", LocalDate.of(1915, 9, 1), null, Set.of()); + + DocumentDensityResult result = documentService.getDensity(noFilters()); + + assertThat(result.buckets()).extracting(MonthBucket::month) + .containsExactly("1915-08", "1915-09"); + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1); + assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3)); + assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1)); + } + + @Test + void getDensity_filtersBySender() { + save("a", LocalDate.of(1915, 8, 3), hans, Set.of()); + save("b", LocalDate.of(1916, 1, 4), hans, Set.of()); + save("c", LocalDate.of(1920, 5, 1), anna, Set.of()); + + DocumentDensityResult result = documentService.getDensity( + new DensityFilters(null, hans.getId(), null, null, null, null, null)); + + assertThat(result.buckets()).extracting(MonthBucket::month) + .containsExactly("1915-08", "1916-01"); + assertThat(result.maxDate()).isEqualTo(LocalDate.of(1916, 1, 4)); + } + + @Test + void getDensity_filtersByTag() { + save("a", LocalDate.of(1915, 8, 3), null, Set.of(familieTag)); + save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag)); + + DocumentDensityResult result = documentService.getDensity( + new DensityFilters(null, null, null, List.of("Familie"), null, null, TagOperator.AND)); + + assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); + } + + @Test + void getDensity_combinesSenderAndTag() { + save("a", LocalDate.of(1915, 8, 3), hans, Set.of(familieTag)); + save("b", LocalDate.of(1916, 1, 4), hans, Set.of(urlaubTag)); + save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag)); + + DocumentDensityResult result = documentService.getDensity( + new DensityFilters(null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND)); + + assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); + } + + @Test + void getDensity_filtersByStatus() { + save("a", LocalDate.of(1915, 8, 3), null, Set.of(), DocumentStatus.UPLOADED); + save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER); + + DocumentDensityResult result = documentService.getDensity( + new DensityFilters(null, null, null, null, null, DocumentStatus.UPLOADED, null)); + + assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08"); + } + + @Test + void getDensity_returnsEmpty_whenNoDocumentsMatch() { + save("a", LocalDate.of(1915, 8, 3), hans, Set.of()); + + DocumentDensityResult result = documentService.getDensity( + new DensityFilters(null, anna.getId(), null, null, null, null, null)); + + assertThat(result.buckets()).isEmpty(); + assertThat(result.minDate()).isNull(); + assertThat(result.maxDate()).isNull(); + } + + @Test + void getDensity_excludesDocumentsWithNullDate() { + save("dated", LocalDate.of(1915, 8, 3), null, Set.of()); + save("undated", null, null, Set.of()); + + DocumentDensityResult result = documentService.getDensity(noFilters()); + + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1); + } + + private void save(String suffix, LocalDate date, Person sender, Set tags) { + save(suffix, date, sender, tags, DocumentStatus.UPLOADED); + } + + private void save(String suffix, LocalDate date, Person sender, Set tags, DocumentStatus status) { + documentRepository.save(Document.builder() + .title("Doc " + suffix) + .originalFilename("doc-" + suffix + "-" + UUID.randomUUID() + ".pdf") + .status(status) + .documentDate(date) + .sender(sender) + .tags(new HashSet<>(tags)) + .build()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index e3390024..2637a9e7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -33,6 +33,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.mock.web.MockMultipartFile; import java.time.LocalDate; @@ -2321,4 +2322,61 @@ class DocumentServiceTest { assertThat(documentService.save(doc)).isEqualTo(doc); verify(documentRepository).save(doc); } + + // ─── getDensity ──────────────────────────────────────────────────────────── + + private static DensityFilters anyFilters() { + return new DensityFilters(null, null, null, null, null, null, null); + } + + @Test + void getDensity_returnsEmptyResult_whenNoDocumentsMatch() { + when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of()); + + DocumentDensityResult result = documentService.getDensity(anyFilters()); + + assertThat(result.buckets()).isEmpty(); + assertThat(result.minDate()).isNull(); + assertThat(result.maxDate()).isNull(); + } + + @Test + void getDensity_groupsMatchingDocumentsByMonth() { + Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build(); + Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build(); + Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build(); + when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c)); + when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); + + DocumentDensityResult result = documentService.getDensity(anyFilters()); + + assertThat(result.buckets()).extracting(MonthBucket::month) + .containsExactly("1915-08", "1915-09"); + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1); + assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3)); + assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1)); + } + + @Test + void getDensity_excludesDocumentsWithNullDate() { + Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build(); + Document undated = Document.builder().documentDate(null).build(); + when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated)); + when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of()); + + DocumentDensityResult result = documentService.getDensity(anyFilters()); + + assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1); + } + + @Test + void getDensity_shortCircuits_whenFtsReturnsNoMatches() { + when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of()); + + DocumentDensityResult result = documentService.getDensity( + new DensityFilters("xyz", null, null, null, null, null, null)); + + assertThat(result.buckets()).isEmpty(); + verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class)); + } } diff --git a/docs/architecture/c4/l3-backend-3b-document-management.puml b/docs/architecture/c4/l3-backend-3b-document-management.puml index 1b68dbeb..a15eb00b 100644 --- a/docs/architecture/c4/l3-backend-3b-document-management.puml +++ b/docs/architecture/c4/l3-backend-3b-document-management.puml @@ -8,7 +8,7 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16") ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)") System_Boundary(backend, "API Backend (Spring Boot)") { - Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.") + Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, batch metadata updates, and per-month density aggregation for the timeline filter widget.") Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).") Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.") Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.") diff --git a/docs/architecture/c4/l3-frontend-3b-document-workflows.puml b/docs/architecture/c4/l3-frontend-3b-document-workflows.puml index 7ba2bb1e..71407f27 100644 --- a/docs/architecture/c4/l3-frontend-3b-document-workflows.puml +++ b/docs/architecture/c4/l3-frontend-3b-document-workflows.puml @@ -8,6 +8,8 @@ Container(backend, "API Backend", "Spring Boot") System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.") + Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.") + Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.") Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.") Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.") Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.") @@ -21,6 +23,9 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Rel(user, homePage, "Searches and browses", "HTTPS / Browser") Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON") +Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON") +Rel(homePage, timelineFilter, "Mounts above the result list") +Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props") Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary") Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart") Rel(docNew, backend, "GET /api/persons, POST /api/documents", "HTTP / JSON + Multipart") diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2ab6edc6..e2a06192 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1045,5 +1045,12 @@ "relation_form_year_placeholder": "z.B. 1920", "person_relationships_heading": "Beziehungen", - "person_relationships_empty": "Noch keine Beziehungen bekannt." + "person_relationships_empty": "Noch keine Beziehungen bekannt.", + + "timeline_aria_label": "Zeitachse Dokumentdichte", + "timeline_clear_selection": "Auswahl zurücksetzen", + "timeline_zoom_reset": "Zurück zur Übersicht", + "timeline_bar_aria_singular": "{when}, 1 Dokument", + "timeline_bar_aria_plural": "{when}, {count} Dokumente", + "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0a39a394..10c3bf2e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1045,5 +1045,12 @@ "relation_form_year_placeholder": "e.g. 1920", "person_relationships_heading": "Relationships", - "person_relationships_empty": "No relationships known yet." + "person_relationships_empty": "No relationships known yet.", + + "timeline_aria_label": "Document density timeline", + "timeline_clear_selection": "Clear selection", + "timeline_zoom_reset": "Reset zoom", + "timeline_bar_aria_singular": "{when}, 1 document", + "timeline_bar_aria_plural": "{when}, {count} documents", + "timeline_dragging_aria_live": "Range {from} to {to} selected" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 29e50e9f..ca2439d9 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1045,5 +1045,12 @@ "relation_form_year_placeholder": "ej. 1920", "person_relationships_heading": "Relaciones", - "person_relationships_empty": "Aún no se conocen relaciones." + "person_relationships_empty": "Aún no se conocen relaciones.", + + "timeline_aria_label": "Cronología de densidad de documentos", + "timeline_clear_selection": "Borrar selección", + "timeline_zoom_reset": "Restablecer zoom", + "timeline_bar_aria_singular": "{when}, 1 documento", + "timeline_bar_aria_plural": "{when}, {count} documentos", + "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado" } diff --git a/frontend/src/lib/document/TimelineBars.svelte b/frontend/src/lib/document/TimelineBars.svelte new file mode 100644 index 00000000..fb0db1ca --- /dev/null +++ b/frontend/src/lib/document/TimelineBars.svelte @@ -0,0 +1,128 @@ + + +

+ {#each filled as bucket, i (bucket.month)} + + {/each} + {#if isDragging} +
+ {/if} +
+ + diff --git a/frontend/src/lib/document/TimelineControls.svelte b/frontend/src/lib/document/TimelineControls.svelte new file mode 100644 index 00000000..0d06af92 --- /dev/null +++ b/frontend/src/lib/document/TimelineControls.svelte @@ -0,0 +1,41 @@ + + +
+ {#if isZoomed} + + {/if} + {#if hasSelection} + + {/if} +
diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte new file mode 100644 index 00000000..5f7b8946 --- /dev/null +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -0,0 +1,204 @@ + + +{#if density !== null} +
+
+ + +
+ + + +
+
+ +
+ {dragLiveMessage} +
+ + +
+{/if} diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts new file mode 100644 index 00000000..404d913b --- /dev/null +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -0,0 +1,659 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { tick } from 'svelte'; +import TimelineDensityFilter from './TimelineDensityFilter.svelte'; +import { formatTickLabel } from './timeline'; +import { getLocale } from '$lib/paraglide/runtime'; +import type { components } from '$lib/generated/api'; + +type MonthBucket = components['schemas']['MonthBucket']; + +afterEach(() => cleanup()); + +const NOOP = () => undefined; + +function makeProps(overrides: Record = {}) { + return { + density: [ + { month: '1915-08', count: 5 }, + { month: '1915-09', count: 2 }, + { month: '1915-10', count: 8 } + ] satisfies MonthBucket[], + minDate: '1915-08-01', + maxDate: '1915-10-31', + from: '', + to: '', + onchange: NOOP, + ...overrides + }; +} + +describe('TimelineDensityFilter — visibility', () => { + it('renders nothing when density is null', async () => { + render(TimelineDensityFilter, makeProps({ density: null, minDate: null, maxDate: null })); + expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull(); + }); + + it('renders the widget when density is populated', async () => { + render(TimelineDensityFilter, makeProps()); + await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument(); + }); + + it('exposes an accessible group label on the widget', async () => { + render(TimelineDensityFilter, makeProps()); + const widget = document.querySelector('[data-testid="timeline-density-filter"]') as HTMLElement; + expect(widget.getAttribute('role')).toBe('group'); + expect(widget.getAttribute('aria-label')).toBeTruthy(); + }); +}); + +describe('TimelineDensityFilter — axes', () => { + it('renders a Y-axis showing the maximum bar count and zero', async () => { + render( + TimelineDensityFilter, + makeProps({ + density: [ + { month: '1915-08', count: 5 }, + { month: '1915-09', count: 12 }, + { month: '1915-10', count: 8 } + ], + minDate: '1915-08-01', + maxDate: '1915-10-31' + }) + ); + + const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement; + expect(yAxis).not.toBeNull(); + expect(yAxis.textContent).toContain('12'); + expect(yAxis.textContent).toContain('0'); + }); + + it('renders X-axis ticks at January boundaries for long month ranges', async () => { + const buckets: MonthBucket[] = []; + for (let m = 8; m <= 12; m++) + buckets.push({ month: `1914-${String(m).padStart(2, '0')}`, count: 1 }); + for (let m = 1; m <= 12; m++) + buckets.push({ month: `1915-${String(m).padStart(2, '0')}`, count: 1 }); + for (let m = 1; m <= 2; m++) + buckets.push({ month: `1916-${String(m).padStart(2, '0')}`, count: 1 }); + + render( + TimelineDensityFilter, + makeProps({ density: buckets, minDate: '1914-08-01', maxDate: '1916-02-29' }) + ); + + const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]'); + expect(ticks.length).toBe(2); + expect(Array.from(ticks).map((t) => t.textContent?.trim())).toEqual( + expect.arrayContaining([expect.stringContaining('1915'), expect.stringContaining('1916')]) + ); + }); + + it('renders X-axis ticks for year-aggregated bars (every 10 years for ~50yr range)', async () => { + const buckets: MonthBucket[] = []; + for (let year = 1900; year <= 1949; year++) { + for (let month = 1; month <= 12; month++) { + buckets.push({ month: `${year}-${String(month).padStart(2, '0')}`, count: 1 }); + } + } + render( + TimelineDensityFilter, + makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1949-12-31' }) + ); + + const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]'); + const labels = Array.from(ticks).map((t) => t.textContent?.trim()); + expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']); + }); +}); + +describe('TimelineDensityFilter — bars', () => { + it('renders one bar per month within the range, including zero-count gaps', async () => { + render( + TimelineDensityFilter, + makeProps({ + density: [ + { month: '1915-08', count: 5 }, + { month: '1915-10', count: 8 } + ], + minDate: '1915-08-01', + maxDate: '1915-10-31' + }) + ); + + const bars = document.querySelectorAll('[data-testid="timeline-bar"]'); + expect(bars.length).toBe(3); + }); + + it('zero-count months get the minimum visible bar height of 2px', async () => { + render( + TimelineDensityFilter, + makeProps({ + density: [{ month: '1915-08', count: 4 }], + minDate: '1915-08-01', + maxDate: '1915-09-30' + }) + ); + + const bars = document.querySelectorAll( + '[data-testid="timeline-bar"] .bar-fill' + ) as NodeListOf; + expect(bars.length).toBe(2); + expect(bars[1].style.height).toBe('2px'); + }); + + it('renders an empty widget without crashing when density is empty array and no range', async () => { + render(TimelineDensityFilter, makeProps({ density: [], minDate: null, maxDate: null })); + await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument(); + expect(document.querySelectorAll('[data-testid="timeline-bar"]').length).toBe(0); + }); +}); + +describe('TimelineDensityFilter — selection', () => { + it('clicking a bar emits the boundary dates of that month via onchange', async () => { + const onchange = vi.fn(); + render(TimelineDensityFilter, makeProps({ onchange })); + + const bars = document.querySelectorAll( + '[data-testid="timeline-bar"]' + ) as NodeListOf; + bars[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onchange).toHaveBeenCalledTimes(1); + expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-08-31' }); + }); + + it('shows a clear button when from/to are set', async () => { + render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' })); + await expect.element(page.getByTestId('timeline-clear')).toBeInTheDocument(); + }); + + it('does not show the clear button when from/to are empty', async () => { + render(TimelineDensityFilter, makeProps()); + expect(document.querySelector('[data-testid="timeline-clear"]')).toBeNull(); + }); + + it('clicking the clear button emits empty dates via onchange', async () => { + const onchange = vi.fn(); + render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30', onchange })); + + const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLButtonElement; + clearBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onchange).toHaveBeenCalledTimes(1); + expect(onchange).toHaveBeenCalledWith({ from: '', to: '' }); + }); + + it('clear button is a real