From ce0c013f0f224db3a336b5a84caa3288af0df368 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:39:32 +0200 Subject: [PATCH 01/52] feat(documents): add document_date index for density aggregation (#385) Issue #385 introduces GET /api/documents/density which aggregates documents by month via date_trunc. Adding the index now keeps the query cheap as the archive grows and removes a future-investigation tax. Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V61__add_document_date_index.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V61__add_document_date_index.sql diff --git a/backend/src/main/resources/db/migration/V61__add_document_date_index.sql b/backend/src/main/resources/db/migration/V61__add_document_date_index.sql new file mode 100644 index 00000000..cba62619 --- /dev/null +++ b/backend/src/main/resources/db/migration/V61__add_document_date_index.sql @@ -0,0 +1,9 @@ +-- Index on documents.meta_date for the timeline density aggregation (issue #385). +-- The new GET /api/documents/density endpoint does GROUP BY date_trunc('month', meta_date) +-- across the full table; an index keeps the aggregation cheap as the archive grows. +-- Cheap to add at any size and removes the future-investigation tax. +-- +-- Note: the entity field is `documentDate` but it's mapped to the PostgreSQL column +-- `meta_date` (see Document.java @Column(name = "meta_date")). + +CREATE INDEX IF NOT EXISTS idx_documents_meta_date ON documents (meta_date); -- 2.49.1 From e61e3797d1b091586d593a290020ed6719d20b1b Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:43:34 +0200 Subject: [PATCH 02/52] feat(documents): add DocumentDensityResult and MonthBucket records (#385) Response shape for the upcoming GET /api/documents/density endpoint. minDate and maxDate are nullable (null on empty archive); buckets is always present. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentDensityResult.java | 13 +++++++++++++ .../familienarchiv/document/MonthBucket.java | 10 ++++++++++ 2 files changed, 23 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/document/MonthBucket.java 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..5c246113 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.document; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +public record DocumentDensityResult( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List buckets, + LocalDate minDate, + LocalDate maxDate +) {} 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 +) {} -- 2.49.1 From c90b42d04558985e36b4b375c018f834efb06e00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:47:59 +0200 Subject: [PATCH 03/52] feat(documents): add density and date-range repository queries (#385) Native SQL aggregations backing GET /api/documents/density: - findDensityByMonth groups documents by truncated meta_date with optional from/to bounds (frontend fills zero-count gaps). - findMinMaxDocumentDate returns the earliest/latest meta_date via projection, null on empty archive. Covered by DocumentDensityIntegrationTest (Testcontainers PostgreSQL): empty archive, single+multi-month grouping, from/to bounds, null meta_date exclusion, min/max edge cases. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentDateRangeProjection.java | 13 +++ .../document/DocumentRepository.java | 24 +++++ .../DocumentDensityIntegrationTest.java | 101 ++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java new file mode 100644 index 00000000..ce52f9a0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDateRangeProjection.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.document; + +import java.time.LocalDate; + +/** + * Spring Data projection for the document_date min/max query backing the timeline + * density widget (issue #385). Column aliases in the native SQL must match these + * getter names exactly. Both values are {@code null} when the documents table is empty. + */ +public interface DocumentDateRangeProjection { + LocalDate getMinDate(); + LocalDate getMaxDate(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index a110d22c..875a6977 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -240,4 +240,28 @@ public interface DocumentRepository extends JpaRepository, JpaSp """) TranscriptionWeeklyStatsProjection findWeeklyStats(); + /** + * Document count grouped by calendar month for the timeline density widget (issue #385). + * Each row is {@code Object[]{ String yearMonth, long count }}. + * Months without documents are not present — the frontend fills gaps from minDate/maxDate. + */ + @Query(nativeQuery = true, value = """ + SELECT TO_CHAR(DATE_TRUNC('month', meta_date), 'YYYY-MM') AS month, + COUNT(*) AS doc_count + FROM documents + WHERE meta_date IS NOT NULL + AND (CAST(:from AS date) IS NULL OR meta_date >= :from) + AND (CAST(:to AS date) IS NULL OR meta_date <= :to) + GROUP BY 1 + ORDER BY 1 + """) + List findDensityByMonth(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * Earliest and latest {@code meta_date} across all documents. Both getters return + * {@code null} when the table holds no documents. + */ + @Query(nativeQuery = true, value = "SELECT MIN(meta_date) AS minDate, MAX(meta_date) AS maxDate FROM documents") + DocumentDateRangeProjection findMinMaxDocumentDate(); + } \ No newline at end of file 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..946d72c3 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java @@ -0,0 +1,101 @@ +package org.raddatz.familienarchiv.document; + +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.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class DocumentDensityIntegrationTest { + + @Autowired + private DocumentRepository documentRepository; + + @Test + void findDensityByMonth_returnsEmptyList_whenNoDocumentsExist() { + List rows = documentRepository.findDensityByMonth(null, null); + + assertThat(rows).isEmpty(); + } + + @Test + void findDensityByMonth_groupsDocumentsByMonth() { + saveDocumentOn(LocalDate.of(1915, 8, 3)); + saveDocumentOn(LocalDate.of(1915, 8, 17)); + saveDocumentOn(LocalDate.of(1915, 9, 1)); + saveDocumentOn(LocalDate.of(1916, 1, 22)); + + List rows = documentRepository.findDensityByMonth(null, null); + + assertThat(rows).hasSize(3); + assertThat(rows).extracting(r -> r[0].toString()) + .containsExactly("1915-08", "1915-09", "1916-01"); + assertThat(rows).extracting(r -> ((Number) r[1]).longValue()) + .containsExactly(2L, 1L, 1L); + } + + @Test + void findDensityByMonth_respectsFromAndToBounds() { + saveDocumentOn(LocalDate.of(1914, 6, 1)); + saveDocumentOn(LocalDate.of(1915, 8, 3)); + saveDocumentOn(LocalDate.of(1916, 1, 22)); + saveDocumentOn(LocalDate.of(1920, 12, 31)); + + List rows = documentRepository.findDensityByMonth( + LocalDate.of(1915, 1, 1), + LocalDate.of(1916, 12, 31)); + + assertThat(rows).extracting(r -> r[0].toString()) + .containsExactly("1915-08", "1916-01"); + } + + @Test + void findDensityByMonth_excludesDocumentsWithoutDate() { + saveDocumentOn(LocalDate.of(1915, 8, 3)); + saveDocumentOn(null); + + List rows = documentRepository.findDensityByMonth(null, null); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0)[0].toString()).isEqualTo("1915-08"); + } + + @Test + void findMinMaxDocumentDate_returnsNullValues_whenNoDocumentsExist() { + DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate(); + + assertThat(result.getMinDate()).isNull(); + assertThat(result.getMaxDate()).isNull(); + } + + @Test + void findMinMaxDocumentDate_returnsEarliestAndLatestDates() { + saveDocumentOn(LocalDate.of(1915, 8, 3)); + saveDocumentOn(LocalDate.of(1899, 1, 15)); + saveDocumentOn(LocalDate.of(1950, 12, 31)); + + DocumentDateRangeProjection result = documentRepository.findMinMaxDocumentDate(); + + assertThat(result.getMinDate()).isEqualTo(LocalDate.of(1899, 1, 15)); + assertThat(result.getMaxDate()).isEqualTo(LocalDate.of(1950, 12, 31)); + } + + private void saveDocumentOn(LocalDate date) { + documentRepository.save(Document.builder() + .title("Doc " + date) + .originalFilename("doc-" + (date == null ? "nodate-" + System.nanoTime() : date.toString()) + ".pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(date) + .build()); + } +} -- 2.49.1 From fbf4725e97ce13aa36f4e9ca19ba4e95c5304976 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:50:05 +0200 Subject: [PATCH 04/52] feat(documents): add DocumentService.getDensity (#385) Maps the repository's Object[] rows into a DocumentDensityResult and pairs them with the archive-wide min/max meta_date range. Read-only, no @Transactional needed. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentService.java | 13 +++++ .../document/DocumentServiceTest.java | 52 +++++++++++++++++++ 2 files changed, 65 insertions(+) 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..9d831380 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -125,6 +125,19 @@ public class DocumentService { return titles; } + /** + * Per-month document counts for the timeline density widget (issue #385). + * Returns only months that have at least one document — the frontend fills + * gaps using the {@code minDate}/{@code maxDate} bounds. + */ + public DocumentDensityResult getDensity(LocalDate from, LocalDate to) { + List buckets = documentRepository.findDensityByMonth(from, to).stream() + .map(row -> new MonthBucket((String) row[0], ((Number) row[1]).intValue())) + .toList(); + DocumentDateRangeProjection range = documentRepository.findMinMaxDocumentDate(); + return new DocumentDensityResult(buckets, range.getMinDate(), range.getMaxDate()); + } + /** * Lädt eine Datei hoch. * - Prüft, ob ein Eintrag (aus Excel) schon existiert. 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..545118f2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -2321,4 +2321,56 @@ class DocumentServiceTest { assertThat(documentService.save(doc)).isEqualTo(doc); verify(documentRepository).save(doc); } + + // ─── getDensity ──────────────────────────────────────────────────────────── + + @Test + void getDensity_returnsEmptyResult_whenArchiveIsEmpty() { + when(documentRepository.findDensityByMonth(null, null)).thenReturn(List.of()); + DocumentDateRangeProjection emptyRange = mock(DocumentDateRangeProjection.class); + when(emptyRange.getMinDate()).thenReturn(null); + when(emptyRange.getMaxDate()).thenReturn(null); + when(documentRepository.findMinMaxDocumentDate()).thenReturn(emptyRange); + + DocumentDensityResult result = documentService.getDensity(null, null); + + assertThat(result.buckets()).isEmpty(); + assertThat(result.minDate()).isNull(); + assertThat(result.maxDate()).isNull(); + } + + @Test + void getDensity_mapsRepositoryRowsToMonthBuckets() { + List rows = List.of( + new Object[]{"1915-08", 2L}, + new Object[]{"1915-09", 1L} + ); + when(documentRepository.findDensityByMonth(null, null)).thenReturn(rows); + DocumentDateRangeProjection range = mock(DocumentDateRangeProjection.class); + when(range.getMinDate()).thenReturn(LocalDate.of(1915, 8, 3)); + when(range.getMaxDate()).thenReturn(LocalDate.of(1915, 9, 1)); + when(documentRepository.findMinMaxDocumentDate()).thenReturn(range); + + DocumentDensityResult result = documentService.getDensity(null, null); + + 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_passesFromAndToBoundsToRepository() { + when(documentRepository.findDensityByMonth(any(), any())).thenReturn(List.of()); + DocumentDateRangeProjection range = mock(DocumentDateRangeProjection.class); + when(documentRepository.findMinMaxDocumentDate()).thenReturn(range); + + LocalDate from = LocalDate.of(1914, 1, 1); + LocalDate to = LocalDate.of(1918, 12, 31); + documentService.getDensity(from, to); + + verify(documentRepository).findDensityByMonth(from, to); + } } -- 2.49.1 From 1060be7def107b0938b758e839c122ce93d203bc Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:52:36 +0200 Subject: [PATCH 05/52] feat(documents): add GET /api/documents/density endpoint (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticated read endpoint backing the timeline density widget. Optional from/to LocalDate query params narrow the aggregation. Response carries Cache-Control: private, max-age=300 so repeated browse sessions skip the aggregation query (per Tobias' devops review). No @RequirePermission needed — inherits the global anyRequest().authenticated() rule. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentController.java | 12 ++++ .../document/DocumentControllerTest.java | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) 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..990218b5 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,16 @@ public class DocumentController { return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable)); } + @GetMapping("/density") + public ResponseEntity density( + @RequestParam(required = false) LocalDate from, + @RequestParam(required = false) LocalDate to) { + DocumentDensityResult result = documentService.getDensity(from, to); + 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/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index ad07afb7..484d6259 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -1240,4 +1240,60 @@ 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(), 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")); + } + + @Test + @WithMockUser + void density_emitsPrivateCacheControlHeader() throws Exception { + when(documentService.getDensity(any(), 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_passesFromAndToParametersToService() throws Exception { + when(documentService.getDensity(any(), any())).thenReturn( + new DocumentDensityResult(List.of(), null, null)); + + mockMvc.perform(get("/api/documents/density") + .param("from", "1914-01-01") + .param("to", "1918-12-31")) + .andExpect(status().isOk()); + + verify(documentService).getDensity( + java.time.LocalDate.of(1914, 1, 1), + java.time.LocalDate.of(1918, 12, 31)); + } } -- 2.49.1 From b31979c4f074d8048fe85e471611854c9ef36b17 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:00:58 +0200 Subject: [PATCH 06/52] chore(frontend): regenerate API types after density endpoint (#385) Adds DocumentDensityResult, MonthBucket and the /api/documents/density path to the openapi-typescript output. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 66 +++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index a90502e4..81ae2d19 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1332,6 +1332,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/density": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["density"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/conversation": { parameters: { query?: never; @@ -2133,16 +2149,16 @@ export interface components { displayName?: string; firstName?: string; lastName?: string; - personType?: string; + /** Format: int64 */ + documentCount?: number; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; - familyMember?: boolean; - notes?: string; alias?: string; - /** Format: int64 */ - documentCount?: number; + notes?: string; + personType?: string; + familyMember?: boolean; }; InferredRelationshipWithPersonDTO: { person: components["schemas"]["PersonNodeDTO"]; @@ -2237,14 +2253,14 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ numberOfElements?: number; empty?: boolean; @@ -2351,6 +2367,19 @@ export interface components { /** Format: date-time */ uploadedAt: string; }; + DocumentDensityResult: { + buckets: components["schemas"]["MonthBucket"][]; + /** Format: date */ + minDate?: string; + /** Format: date */ + maxDate?: string; + }; + MonthBucket: { + /** @example 1915-08 */ + month: string; + /** Format: int32 */ + count: number; + }; DashboardResumeDTO: { /** Format: uuid */ documentId: string; @@ -4927,6 +4956,29 @@ export interface operations { }; }; }; + density: { + parameters: { + query?: { + from?: string; + to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DocumentDensityResult"]; + }; + }; + }; + }; getConversation: { parameters: { query: { -- 2.49.1 From 142459b9168ee239da87e883e6e1c6bbb96439df Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:02:15 +0200 Subject: [PATCH 07/52] feat(i18n): add timeline density widget keys (#385) Five new keys across de/en/es for the upcoming TimelineDensityFilter: aria label, clear selection, abbreviated count label, loading state, and parametrised filtered-count message. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 8 +++++++- frontend/messages/en.json | 8 +++++++- frontend/messages/es.json | 8 +++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2ab6edc6..39b11116 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1045,5 +1045,11 @@ "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_count_label": "Dok.", + "timeline_loading": "Lade Zeitachse…", + "timeline_filtered_count": "{count} Dokumente im Zeitraum" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0a39a394..6f910726 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1045,5 +1045,11 @@ "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_count_label": "docs", + "timeline_loading": "Loading timeline…", + "timeline_filtered_count": "{count} documents in range" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 29e50e9f..dbfa346c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1045,5 +1045,11 @@ "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_count_label": "docs", + "timeline_loading": "Cargando cronología…", + "timeline_filtered_count": "{count} documentos en el rango" } -- 2.49.1 From 5fdcc95c3d9e8221332b2bdd1c45373ea3e6ab9a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:04:21 +0200 Subject: [PATCH 08/52] feat(documents): add timeline helpers (boundary + gap-fill) (#385) Pure utilities backing the TimelineDensityFilter component: - monthBoundaryFrom/To convert YYYY-MM into LocalDate strings the existing /api/documents/search accepts (first/last day of the month). - buildMonthSequence enumerates months between minDate and maxDate, crossing year boundaries. - fillDensityGaps merges sparse backend buckets with the full month sequence, producing zero-count entries for months that the API omitted. 14 unit tests cover leap years, year boundaries, null inputs, and out-of-order buckets. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/document/timeline.spec.ts | 108 +++++++++++++++++++++ frontend/src/lib/document/timeline.ts | 47 +++++++++ 2 files changed, 155 insertions(+) create mode 100644 frontend/src/lib/document/timeline.spec.ts create mode 100644 frontend/src/lib/document/timeline.ts diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts new file mode 100644 index 00000000..871eec52 --- /dev/null +++ b/frontend/src/lib/document/timeline.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { + monthBoundaryFrom, + monthBoundaryTo, + buildMonthSequence, + fillDensityGaps +} from './timeline'; + +describe('monthBoundaryFrom', () => { + it('returns the first day of the given month', () => { + expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01'); + }); + + it('handles January', () => { + expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01'); + }); +}); + +describe('monthBoundaryTo', () => { + it('returns the last day of a 31-day month', () => { + expect(monthBoundaryTo('1915-08')).toBe('1915-08-31'); + }); + + it('returns the last day of a 30-day month', () => { + expect(monthBoundaryTo('1915-04')).toBe('1915-04-30'); + }); + + it('returns 28 for February in a non-leap year', () => { + expect(monthBoundaryTo('1915-02')).toBe('1915-02-28'); + }); + + it('returns 29 for February in a leap year', () => { + expect(monthBoundaryTo('1916-02')).toBe('1916-02-29'); + }); +}); + +describe('buildMonthSequence', () => { + it('returns a single month when min and max are in the same month', () => { + expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']); + }); + + it('returns months from minDate through maxDate inclusive', () => { + expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([ + '1915-08', + '1915-09', + '1915-10', + '1915-11' + ]); + }); + + it('crosses year boundaries correctly', () => { + expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([ + '1915-11', + '1915-12', + '1916-01', + '1916-02' + ]); + }); + + it('returns empty array when minDate or maxDate is null', () => { + expect(buildMonthSequence(null, '1915-08-01')).toEqual([]); + expect(buildMonthSequence('1915-08-01', null)).toEqual([]); + expect(buildMonthSequence(null, null)).toEqual([]); + }); +}); + +describe('fillDensityGaps', () => { + it('returns empty array when minDate or maxDate is null', () => { + expect(fillDensityGaps([], null, null)).toEqual([]); + }); + + it('preserves existing buckets and adds zero-count buckets for missing months', () => { + const buckets = [ + { month: '1915-08', count: 5 }, + { month: '1915-11', count: 2 } + ]; + + const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30'); + + expect(result).toEqual([ + { month: '1915-08', count: 5 }, + { month: '1915-09', count: 0 }, + { month: '1915-10', count: 0 }, + { month: '1915-11', count: 2 } + ]); + }); + + it('returns all-zero sequence when buckets array is empty', () => { + const result = fillDensityGaps([], '1915-08-03', '1915-10-15'); + + expect(result).toEqual([ + { month: '1915-08', count: 0 }, + { month: '1915-09', count: 0 }, + { month: '1915-10', count: 0 } + ]); + }); + + it('keeps results sorted chronologically even when buckets arrive out of order', () => { + const buckets = [ + { month: '1915-10', count: 3 }, + { month: '1915-08', count: 1 } + ]; + + const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31'); + + expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']); + }); +}); diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts new file mode 100644 index 00000000..2fa09899 --- /dev/null +++ b/frontend/src/lib/document/timeline.ts @@ -0,0 +1,47 @@ +import type { components } from '$lib/generated/api'; + +type MonthBucket = components['schemas']['MonthBucket']; + +export function monthBoundaryFrom(yearMonth: string): string { + return `${yearMonth}-01`; +} + +export function monthBoundaryTo(yearMonth: string): string { + const [year, month] = yearMonth.split('-').map(Number); + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return `${yearMonth}-${String(lastDay).padStart(2, '0')}`; +} + +export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] { + if (!minDate || !maxDate) return []; + + const [minY, minM] = minDate.split('-').map(Number); + const [maxY, maxM] = maxDate.split('-').map(Number); + + const sequence: string[] = []; + let year = minY; + let month = minM; + + while (year < maxY || (year === maxY && month <= maxM)) { + sequence.push(`${year}-${String(month).padStart(2, '0')}`); + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + + return sequence; +} + +export function fillDensityGaps( + buckets: MonthBucket[], + minDate: string | null, + maxDate: string | null +): MonthBucket[] { + const sequence = buildMonthSequence(minDate, maxDate); + if (sequence.length === 0) return []; + + const counts = new Map(buckets.map((b) => [b.month, b.count])); + return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 })); +} -- 2.49.1 From ad82f2e1e2857c7eec5376949472d7f94a6a4050 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:06:57 +0200 Subject: [PATCH 09/52] feat(documents): add fetchDensity helper and /documents/+page.ts (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The density data is fetched only on tablet/desktop (sm:+ breakpoint) and when ?view=calendar is not set — mobile users and the future calendar view (#386) skip the request entirely. Lives in +page.ts (client-side) so the matchMedia gate can run in the browser; +page.server.ts continues to handle the document search. Non-ok responses and network failures degrade to an empty bucket list rather than throwing, so the document list keeps rendering. 5 unit tests cover the gating + graceful degradation paths. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/document/timeline.spec.ts | 59 +++++++++++++++++++++- frontend/src/lib/document/timeline.ts | 37 ++++++++++++++ frontend/src/routes/documents/+page.ts | 9 ++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/documents/+page.ts diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts index 871eec52..ca4e177b 100644 --- a/frontend/src/lib/document/timeline.spec.ts +++ b/frontend/src/lib/document/timeline.spec.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { monthBoundaryFrom, monthBoundaryTo, buildMonthSequence, - fillDensityGaps + fillDensityGaps, + fetchDensity } from './timeline'; describe('monthBoundaryFrom', () => { @@ -106,3 +107,57 @@ describe('fillDensityGaps', () => { expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']); }); }); + +describe('fetchDensity', () => { + it('skips fetch and returns null density on mobile', async () => { + const fetch = vi.fn(); + + const result = await fetchDensity(fetch, null, false); + + expect(fetch).not.toHaveBeenCalled(); + expect(result).toEqual({ density: null, minDate: null, maxDate: null }); + }); + + it('skips fetch when view is calendar', async () => { + const fetch = vi.fn(); + + const result = await fetchDensity(fetch, 'calendar', true); + + expect(fetch).not.toHaveBeenCalled(); + expect(result).toEqual({ density: null, minDate: null, maxDate: null }); + }); + + it('calls /api/documents/density and returns body on desktop, list view', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + buckets: [{ month: '1915-08', count: 3 }], + minDate: '1915-08-01', + maxDate: '1916-12-31' + }) + }); + + const result = await fetchDensity(fetch, null, true); + + expect(fetch).toHaveBeenCalledWith('/api/documents/density'); + expect(result.density).toEqual([{ month: '1915-08', count: 3 }]); + expect(result.minDate).toBe('1915-08-01'); + expect(result.maxDate).toBe('1916-12-31'); + }); + + it('returns empty density and null bounds when the API responds non-ok', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + + const result = await fetchDensity(fetch, null, true); + + expect(result).toEqual({ density: [], minDate: null, maxDate: null }); + }); + + it('treats fetch rejection as a graceful degradation, not an error', async () => { + const fetch = vi.fn().mockRejectedValue(new TypeError('Network down')); + + const result = await fetchDensity(fetch, null, true); + + expect(result).toEqual({ density: [], minDate: null, maxDate: null }); + }); +}); diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts index 2fa09899..3406c06b 100644 --- a/frontend/src/lib/document/timeline.ts +++ b/frontend/src/lib/document/timeline.ts @@ -1,6 +1,16 @@ import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; +type DocumentDensityResult = components['schemas']['DocumentDensityResult']; + +export type DensityState = { + density: MonthBucket[] | null; + minDate: string | null; + maxDate: string | null; +}; + +const SKIP: DensityState = { density: null, minDate: null, maxDate: null }; +const EMPTY: DensityState = { density: [], minDate: null, maxDate: null }; export function monthBoundaryFrom(yearMonth: string): string { return `${yearMonth}-01`; @@ -45,3 +55,30 @@ export function fillDensityGaps( const counts = new Map(buckets.map((b) => [b.month, b.count])); return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 })); } + +/** + * Loads the density data for the timeline widget. Mobile (sm: breakpoint and below) + * and calendar view both skip the request entirely — the widget isn't rendered + * there. A non-ok response or network failure degrades to an empty bucket list + * instead of throwing, so the document list page keeps rendering. + */ +export async function fetchDensity( + fetch: typeof globalThis.fetch, + view: string | null, + isDesktop: boolean +): Promise { + if (!isDesktop || view === 'calendar') return SKIP; + + try { + const response = await fetch('/api/documents/density'); + if (!response.ok) return EMPTY; + const body = (await response.json()) as DocumentDensityResult; + return { + density: body.buckets, + minDate: body.minDate ?? null, + maxDate: body.maxDate ?? null + }; + } catch { + return EMPTY; + } +} diff --git a/frontend/src/routes/documents/+page.ts b/frontend/src/routes/documents/+page.ts new file mode 100644 index 00000000..de74bdce --- /dev/null +++ b/frontend/src/routes/documents/+page.ts @@ -0,0 +1,9 @@ +import { browser } from '$app/environment'; +import { fetchDensity } from '$lib/document/timeline'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url, fetch }) => { + const view = url.searchParams.get('view'); + const isDesktop = browser && window.matchMedia('(min-width: 640px)').matches; + return await fetchDensity(fetch, view, isDesktop); +}; -- 2.49.1 From d43d73f231f3666c9b5239f9666274907d6e03ac Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:13:58 +0200 Subject: [PATCH 10/52] feat(documents): add TimelineDensityFilter component (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Density timeline widget: one bar per month within minDate/maxDate, proportional heights, click-to-select-month with onchange callback, and a clear button when a selection is active. Notable details: - Hidden entirely when density is null (mobile / calendar view; +page.ts controls the gating). - Zero-count months render at 2 px so the time axis stays readable (Leonie's design intent overrides AC's literal "no bar" wording). - Component-scoped --timeline-bar-idle CSS var for the dim idle color (light: mint-tinted rgba; dark: structural navy #0d3358 — meets WCAG 1.4.11 3:1 against surface, unlike the spec's #0E2535). - Clear button is a real + {/each} + + + {#if hasSelection} + + {/if} + +{/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..5d86ae09 --- /dev/null +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TimelineDensityFilter from './TimelineDensityFilter.svelte'; +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 — 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 - {/if} +
+ {#if canZoomIn} + + {/if} + {#if isZoomed} + + {/if} + {#if hasSelection} + + {/if} +
{/if} diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts index 66a46d4c..bff43b7d 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -175,6 +175,95 @@ describe('TimelineDensityFilter — year-granularity fallback', () => { }); }); +describe('TimelineDensityFilter — zoom-in', () => { + it('does not show the zoom button when there is no selection', async () => { + render(TimelineDensityFilter, makeProps()); + expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull(); + }); + + it('shows the zoom button when from/to are set and not yet zoomed', async () => { + render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' })); + await expect.element(page.getByTestId('timeline-zoom-in')).toBeInTheDocument(); + }); + + it('hides the zoom button when zoomFrom/zoomTo are already set', async () => { + render( + TimelineDensityFilter, + makeProps({ + from: '1915-08-01', + to: '1915-09-30', + zoomFrom: '1915-08-01', + zoomTo: '1915-09-30' + }) + ); + expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull(); + }); + + it('clicking the zoom button emits onzoomchange with the current selection', async () => { + const onzoomchange = vi.fn(); + render( + TimelineDensityFilter, + makeProps({ from: '1915-08-01', to: '1915-09-30', onzoomchange }) + ); + + const zoomBtn = document.querySelector('[data-testid="timeline-zoom-in"]') as HTMLButtonElement; + zoomBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onzoomchange).toHaveBeenCalledWith({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }); + }); + + it('shows the reset-zoom button only when zoomed', async () => { + render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' })); + await expect.element(page.getByTestId('timeline-zoom-reset')).toBeInTheDocument(); + }); + + it('clicking reset-zoom emits onzoomchange(null)', async () => { + const onzoomchange = vi.fn(); + render( + TimelineDensityFilter, + makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30', onzoomchange }) + ); + + const resetBtn = document.querySelector( + '[data-testid="timeline-zoom-reset"]' + ) as HTMLButtonElement; + resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(onzoomchange).toHaveBeenCalledWith(null); + }); + + it('when zoomed, only bars within the zoom range are rendered', async () => { + // 21-year span normally collapses to year mode (>240 months handled + // elsewhere). Zooming in to a 3-month window should restore month bars. + const buckets: MonthBucket[] = []; + for (let year = 1900; year <= 1920; 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: '1920-12-31', + zoomFrom: '1910-06-01', + zoomTo: '1910-08-31' + }) + ); + + const bars = document.querySelectorAll('[data-testid="timeline-bar"]'); + // 3 months in zoom range + expect(bars.length).toBe(3); + expect(bars[0].getAttribute('aria-label')?.startsWith('1910-06 ·')).toBe(true); + expect(bars[2].getAttribute('aria-label')?.startsWith('1910-08 ·')).toBe(true); + }); +}); + describe('TimelineDensityFilter — drag-to-select-range', () => { function pointerDown(el: HTMLElement) { const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }); diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts index 097705f5..2e64cbb2 100644 --- a/frontend/src/lib/document/timeline.spec.ts +++ b/frontend/src/lib/document/timeline.spec.ts @@ -8,7 +8,8 @@ import { buildDensityUrl, aggregateToYears, selectionBoundaryFrom, - selectionBoundaryTo + selectionBoundaryTo, + clipBucketsToRange } from './timeline'; describe('monthBoundaryFrom', () => { @@ -141,6 +142,37 @@ describe('aggregateToYears', () => { }); }); +describe('clipBucketsToRange', () => { + const buckets = [ + { month: '1915-08', count: 5 }, + { month: '1915-09', count: 2 }, + { month: '1915-10', count: 8 }, + { month: '1915-11', count: 3 } + ]; + + it('returns the original buckets when range bounds are null', () => { + expect(clipBucketsToRange(buckets, null, null)).toBe(buckets); + }); + + it('keeps only buckets whose month falls within the range', () => { + expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([ + { month: '1915-09', count: 2 }, + { month: '1915-10', count: 8 } + ]); + }); + + it('returns an empty array when the range excludes everything', () => { + expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]); + }); + + it('treats partial dates correctly when bounds cross month boundaries', () => { + expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([ + { month: '1915-09', count: 2 }, + { month: '1915-10', count: 8 } + ]); + }); +}); + describe('selectionBoundaryFrom / To', () => { it('handles month labels (YYYY-MM)', () => { expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01'); diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts index ac6836bb..1fbae690 100644 --- a/frontend/src/lib/document/timeline.ts +++ b/frontend/src/lib/document/timeline.ts @@ -56,6 +56,23 @@ export function fillDensityGaps( return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 })); } +/** + * Returns only the month buckets whose YYYY-MM falls inside the provided + * `[fromInclusive, toInclusive]` ISO date range. When either bound is null the + * input array is returned unchanged. Used by the timeline's zoom-in tool to + * narrow the visible bars without refetching data. + */ +export function clipBucketsToRange( + buckets: MonthBucket[], + fromInclusive: string | null, + toInclusive: string | null +): MonthBucket[] { + if (!fromInclusive || !toInclusive) return buckets; + const fromMonth = fromInclusive.slice(0, 7); + const toMonth = toInclusive.slice(0, 7); + return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth); +} + /** * Aggregates month-granular buckets into one entry per year. Month strings are * truncated to "YYYY" and counts are summed. Used when the date span is too diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index fadb201a..e69b00a4 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -51,6 +51,8 @@ type FilterSnapshot = { dir: string; tagQ: string; tagOp: 'AND' | 'OR'; + zoomFrom?: string | null; + zoomTo?: string | null; }; /** @@ -72,6 +74,8 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte if (filters.dir) params.set('dir', filters.dir); if (filters.tagQ) params.set('tagQ', filters.tagQ); if (filters.tagOp === 'OR') params.set('tagOp', 'OR'); + if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom); + if (filters.zoomTo) params.set('zoomTo', filters.zoomTo); if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage)); return params; } @@ -80,7 +84,7 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte * Rebuilds the URL from the CURRENT local filter state. `page` is intentionally * not carried over — any filter change implicitly resets back to page 0. */ -function triggerSearch() { +function triggerSearch(zoomOverride?: { zoomFrom: string | null; zoomTo: string | null }) { const params = buildSearchParams({ q, from, @@ -91,7 +95,9 @@ function triggerSearch() { sort, dir, tagQ, - tagOp: tagOperator + tagOp: tagOperator, + zoomFrom: zoomOverride ? zoomOverride.zoomFrom : data.zoomFrom, + zoomTo: zoomOverride ? zoomOverride.zoomTo : data.zoomTo }); goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true }); } @@ -240,6 +246,8 @@ $effect(() => { density={data.density} minDate={data.minDate} maxDate={data.maxDate} + zoomFrom={data.zoomFrom} + zoomTo={data.zoomTo} from={from} to={to} onchange={(event) => { @@ -247,6 +255,12 @@ $effect(() => { to = event.to; triggerSearch(); }} + onzoomchange={(event) => { + triggerSearch({ + zoomFrom: event?.zoomFrom ?? null, + zoomTo: event?.zoomTo ?? null + }); + }} /> diff --git a/frontend/src/routes/documents/+page.ts b/frontend/src/routes/documents/+page.ts index d79459bb..dace6efe 100644 --- a/frontend/src/routes/documents/+page.ts +++ b/frontend/src/routes/documents/+page.ts @@ -19,5 +19,8 @@ export const load: PageLoad = async ({ url, fetch, data }) => { }; const density = await fetchDensity(fetch, view, isDesktop, filters); - return { ...data, ...density }; + const zoomFrom = url.searchParams.get('zoomFrom'); + const zoomTo = url.searchParams.get('zoomTo'); + + return { ...data, ...density, zoomFrom, zoomTo }; }; diff --git a/frontend/src/routes/documents/page.svelte.spec.ts b/frontend/src/routes/documents/page.svelte.spec.ts index e0c0f117..2016f894 100644 --- a/frontend/src/routes/documents/page.svelte.spec.ts +++ b/frontend/src/routes/documents/page.svelte.spec.ts @@ -176,4 +176,55 @@ describe('documents page — timeline density widget', () => { expect(url).toContain('from=1915-08-01'); expect(url).toContain('to=1915-08-31'); }); + + it('clicking the zoom-in button writes zoomFrom/zoomTo URL params', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { + data: makeData({ + density: [ + { month: '1915-08', count: 3 }, + { month: '1915-09', count: 2 } + ], + minDate: '1915-08-01', + maxDate: '1915-09-30', + from: '1915-08-01', + to: '1915-09-30' + }) + }); + + const zoomBtn = document.querySelector('[data-testid="timeline-zoom-in"]') as HTMLButtonElement; + zoomBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(goto).toHaveBeenCalledOnce(); + const [url] = vi.mocked(goto).mock.calls[0]; + expect(url).toContain('zoomFrom=1915-08-01'); + expect(url).toContain('zoomTo=1915-09-30'); + }); + + it('clicking reset-zoom drops zoomFrom/zoomTo from the URL', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(Page, { + data: makeData({ + density: [{ month: '1915-08', count: 3 }], + minDate: '1915-08-01', + maxDate: '1915-08-31', + zoomFrom: '1915-08-01', + zoomTo: '1915-08-31' + }) + }); + + const resetBtn = document.querySelector( + '[data-testid="timeline-zoom-reset"]' + ) as HTMLButtonElement; + resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(goto).toHaveBeenCalledOnce(); + const [url] = vi.mocked(goto).mock.calls[0]; + expect(url).not.toContain('zoomFrom='); + expect(url).not.toContain('zoomTo='); + }); }); -- 2.49.1 From 5d92f5a32b8a610e372bd6d60ac320dfc26025f8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 08:54:48 +0200 Subject: [PATCH 19/52] refactor(documents): rework timeline UX after live testing (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the discrete zoom-in button with a Graylog-style drag-to-zoom range selector and adds X/Y axis labels so the chart is readable. Drag interaction - Pointerdown on a bar attaches document-level pointermove/pointerup/ pointercancel listeners; pointermove maps clientX to a bar index via the row's bounding rect, so the mint-bordered window expands smoothly even when the cursor leaves the bar or the chart entirely. - pointerup commits filter + zoom atomically. Same-bar release on a year bar (year-aggregated mode) zooms into that year's months; same-bar release on a month bar emits filter-only. - setPointerCapture removed — it was suppressing pointerenter on sibling bars and preventing the drag window from expanding. - Bar buttons are now h-full so the entire 80 px column is the hit target, not just the visible bar height. Axis labels - Y-axis: max-count and 0 labels left of the bar area. - X-axis: tickIndicesFor() picks decadal years for long ranges, evenly spaced months for short year-zoom views, January boundaries for multi-year month ranges. formatTickLabel() drops the year when the visible range is a single year so 12-month zooms read "Jan Feb Mär…". Co-Authored-By: Claude Opus 4.7 --- .../lib/document/TimelineDensityFilter.svelte | 239 +++++++++++++----- .../TimelineDensityFilter.svelte.spec.ts | 158 +++++++++--- frontend/src/lib/document/timeline.spec.ts | 85 ++++++- frontend/src/lib/document/timeline.ts | 58 +++++ frontend/src/routes/documents/+page.svelte | 11 +- .../src/routes/documents/page.svelte.spec.ts | 22 +- 6 files changed, 454 insertions(+), 119 deletions(-) diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 67b8d824..6b32cbd1 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -5,12 +5,22 @@ import { aggregateToYears, clipBucketsToRange, selectionBoundaryFrom, - selectionBoundaryTo + selectionBoundaryTo, + tickIndicesFor, + formatTickLabel } from '$lib/document/timeline'; +import { getLocale } from '$lib/paraglide/runtime'; import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; -type SelectionEvent = { from: string; to: string }; +// Drag emits filter + zoom atomically (Graylog-style range selector). +// Single click and clear emit filter only — zoom fields are absent. +type SelectionEvent = { + from: string; + to: string; + zoomFrom?: string | null; + zoomTo?: string | null; +}; type ZoomEvent = { zoomFrom: string; zoomTo: string }; const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20 @@ -52,12 +62,6 @@ const filled = $derived( ); const isZoomed = $derived(zoomFrom !== null && zoomTo !== null); -const canZoomIn = $derived(hasSelection && !isZoomed); - -function zoomIn() { - if (from === '' || to === '') return; - onzoomchange?.({ zoomFrom: from, zoomTo: to }); -} function resetZoom() { onzoomchange?.(null); @@ -69,6 +73,7 @@ const hasSelection = $derived(from !== '' || to !== ''); let dragStartIndex: number | null = $state(null); let dragEndIndex: number | null = $state(null); +let rowEl: HTMLDivElement | undefined = $state(); // Set when a pointerup just emitted a selection so the synthesized click that // follows mouse interaction is suppressed (we'd otherwise emit twice). Keyboard // Enter/Space on a focused button still fires `click` without preceding @@ -107,15 +112,76 @@ function isInDragPreview(index: number): boolean { return index >= dragLowIndex && index <= dragHighIndex; } -function emitSelection(startIndex: number, endIndex: number) { +function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) { const lo = Math.min(startIndex, endIndex); const hi = Math.max(startIndex, endIndex); const startLabel = filled[lo]?.month; const endLabel = filled[hi]?.month; if (!startLabel || !endLabel) return; - onchange({ - from: selectionBoundaryFrom(startLabel), - to: selectionBoundaryTo(endLabel) + const selFrom = selectionBoundaryFrom(startLabel); + const selTo = selectionBoundaryTo(endLabel); + if (includeZoom) { + onchange({ from: selFrom, to: selTo, zoomFrom: selFrom, zoomTo: selTo }); + } else { + onchange({ from: selFrom, to: selTo }); + } +} + +// Maps a viewport X-coordinate to a bar index by measuring the row, so +// pointermove during drag works even when the cursor leaves the original bar +// or the graph entirely. Pointer capture isn't usable here because it would +// re-target click and suppress pointerenter on sibling bars. +function indexFromClientX(clientX: number): number | null { + if (!rowEl || filled.length === 0) return null; + const rect = rowEl.getBoundingClientRect(); + const x = clientX - rect.left; + if (x < 0) return 0; + if (x >= rect.width) return filled.length - 1; + const barWidth = rect.width / filled.length; + return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth))); +} + +function handleDocumentMove(e: PointerEvent) { + const idx = indexFromClientX(e.clientX); + if (idx !== null) dragEndIndex = idx; +} + +function handleDocumentUp() { + cleanupDragListeners(); + finalizeDrag(); +} + +function handleDocumentCancel() { + cleanupDragListeners(); + dragStartIndex = null; + dragEndIndex = null; +} + +function cleanupDragListeners() { + document.removeEventListener('pointermove', handleDocumentMove); + document.removeEventListener('pointerup', handleDocumentUp); + document.removeEventListener('pointercancel', handleDocumentCancel); +} + +function finalizeDrag() { + if (dragStartIndex === null || dragEndIndex === null) return; + const start = dragStartIndex; + const end = dragEndIndex; + dragStartIndex = null; + dragEndIndex = null; + const isRangeDrag = start !== end; + const startLabel = filled[start]?.month; + // Range drag → atomic zoom + filter. + // Same-bar release on a year bar → zoom into that year's months. + // Same-bar release on a month bar → filter only. + const includeZoom = isRangeDrag || (!!startLabel && isYearLabel(startLabel)); + emitSelection(start, end, includeZoom); + // Suppress the synthesized click that follows pointerup so we don't + // double-emit. Keyboard Enter/Space fires click without a preceding + // pointerup and stays the keyboard accessibility surface. + suppressClick = true; + queueMicrotask(() => { + suppressClick = false; }); } @@ -123,7 +189,9 @@ function handlePointerDown(event: PointerEvent, index: number) { if (event.button !== 0) return; dragStartIndex = index; dragEndIndex = index; - (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId); + document.addEventListener('pointermove', handleDocumentMove); + document.addEventListener('pointerup', handleDocumentUp); + document.addEventListener('pointercancel', handleDocumentCancel); } function handlePointerEnter(index: number) { @@ -131,20 +199,8 @@ function handlePointerEnter(index: number) { dragEndIndex = index; } -function handlePointerUp() { - if (dragStartIndex === null || dragEndIndex === null) return; - emitSelection(dragStartIndex, dragEndIndex); - dragStartIndex = null; - dragEndIndex = null; - suppressClick = true; - queueMicrotask(() => { - suppressClick = false; - }); -} - -function handlePointerCancel() { - dragStartIndex = null; - dragEndIndex = null; +function isYearLabel(label: string): boolean { + return label.length === 4; } function handleClick(index: number) { @@ -152,8 +208,28 @@ function handleClick(index: number) { suppressClick = false; return; } - emitSelection(index, index); + const label = filled[index]?.month; + // Click on a year bar zooms into that year so the user can see month-level + // density. Click on a month bar just filters. + const includeZoom = !!label && isYearLabel(label); + emitSelection(index, index, includeZoom); } + +const dragWindowLeftPct = $derived.by(() => { + if (!isDragging || dragLowIndex === null || filled.length === 0) return 0; + return (dragLowIndex / filled.length) * 100; +}); +const dragWindowRightPct = $derived.by(() => { + if (!isDragging || dragHighIndex === null || filled.length === 0) return 100; + return ((filled.length - dragHighIndex - 1) / filled.length) * 100; +}); + +const tickIndices = $derived(tickIndicesFor(filled)); +const omitTickYear = $derived.by(() => { + if (filled.length === 0 || filled[0].month.length === 4) return false; + const firstYear = filled[0].month.slice(0, 4); + return filled.every((b) => b.month.slice(0, 4) === firstYear); +}); {#if density !== null} @@ -163,43 +239,71 @@ function handleClick(index: number) { aria-label={m.timeline_aria_label()} class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm" > -
- {#each filled as bucket, i (bucket.month)} - - {/each} + {#each filled as bucket, i (bucket.month)} + + {/each} + {#if isDragging} +
+ {/if} +
+ + +
- {#if canZoomIn} - - {/if} {#if isZoomed} + {/each} + {#if isDragging} +
+ {/if} +
+ + diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 5d5e4554..906aab86 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -10,6 +10,7 @@ import { formatTickLabel } from '$lib/document/timeline'; import { getLocale } from '$lib/paraglide/runtime'; +import TimelineBars from '$lib/document/TimelineBars.svelte'; import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; @@ -24,7 +25,6 @@ type SelectionEvent = { type ZoomEvent = { zoomFrom: string; zoomTo: string }; const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20 -const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months // Above this threshold, month bars compress to sub-pixel widths in the flex // row; we collapse to year granularity so each bar stays clickable. const MONTH_GRANULARITY_LIMIT = 240; @@ -91,11 +91,6 @@ const dragHighIndex = $derived( : dragStartIndex ); -function barHeight(count: number): number { - if (count === 0) return ZERO_COUNT_BAR_HEIGHT; - return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_HEIGHT); -} - function clearSelection() { onchange({ from: '', to: '' }); } @@ -264,41 +259,20 @@ const omitTickYear = $derived.by(() => {
-
- {#each filled as bucket, i (bucket.month)} - - {/each} - {#if isDragging} -
- {/if} -
+
{
{/if} - - -- 2.49.1 From 219d9a816e60ee069b8cddab9fa6cbc4a013a9d3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:06:46 +0200 Subject: [PATCH 30/52] refactor(documents): extract Y-axis and X-axis components (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix's review named "TimelineAxes" as one of four split targets. The Y-axis and X-axis don't sit adjacent in the DOM — Y is a flex sibling of the bars+X column — so two single-purpose components beats a discriminator-prop component. tickIndicesFor and the omitTickYear derivation move to TimelineXAxis where they belong. Closes part 2 of Felix's component-split concern. Co-Authored-By: Claude Opus 4.7 --- .../lib/document/TimelineDensityFilter.svelte | 37 ++--------------- .../src/lib/document/TimelineXAxis.svelte | 40 +++++++++++++++++++ .../src/lib/document/TimelineYAxis.svelte | 19 +++++++++ 3 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 frontend/src/lib/document/TimelineXAxis.svelte create mode 100644 frontend/src/lib/document/TimelineYAxis.svelte diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 906aab86..62d7f4d5 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -6,11 +6,12 @@ import { clipBucketsToRange, selectionBoundaryFrom, selectionBoundaryTo, - tickIndicesFor, formatTickLabel } from '$lib/document/timeline'; import { getLocale } from '$lib/paraglide/runtime'; import TimelineBars from '$lib/document/TimelineBars.svelte'; +import TimelineYAxis from '$lib/document/TimelineYAxis.svelte'; +import TimelineXAxis from '$lib/document/TimelineXAxis.svelte'; import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; @@ -219,8 +220,6 @@ const dragWindowRightPct = $derived.by(() => { return ((filled.length - dragHighIndex - 1) / filled.length) * 100; }); -const tickIndices = $derived(tickIndicesFor(filled)); - // While dragging, expose the live preview range to assistive tech via a // polite live region. Empty text outside drag avoids announcing residual state. const dragLiveMessage = $derived.by(() => { @@ -233,11 +232,6 @@ const dragLiveMessage = $derived.by(() => { to: formatTickLabel(toLabel, getLocale()) }); }); -const omitTickYear = $derived.by(() => { - if (filled.length === 0 || filled[0].month.length === 4) return false; - const firstYear = filled[0].month.slice(0, 4); - return filled.every((b) => b.month.slice(0, 4) === firstYear); -}); {#if density !== null} @@ -248,15 +242,7 @@ const omitTickYear = $derived.by(() => { class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm" >
- +
{ onbarclick={handleClick} /> - +
diff --git a/frontend/src/lib/document/TimelineXAxis.svelte b/frontend/src/lib/document/TimelineXAxis.svelte new file mode 100644 index 00000000..b63975d7 --- /dev/null +++ b/frontend/src/lib/document/TimelineXAxis.svelte @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/lib/document/TimelineYAxis.svelte b/frontend/src/lib/document/TimelineYAxis.svelte new file mode 100644 index 00000000..15f17208 --- /dev/null +++ b/frontend/src/lib/document/TimelineYAxis.svelte @@ -0,0 +1,19 @@ + + + -- 2.49.1 From e5739d7f8e59681cff55f69388550bf245051b62 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:07:45 +0200 Subject: [PATCH 31/52] refactor(documents): extract TimelineControls (#385) Splits the reset-zoom and clear buttons out of the orchestrator into their own component. Closes part 3 (final) of Felix's component-split concern. Orchestrator now composes four single-purpose children (TimelineBars, TimelineYAxis, TimelineXAxis, TimelineControls) and keeps only the pointer choreography that links them. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/document/TimelineControls.svelte | 41 +++++++++++++++++++ .../lib/document/TimelineDensityFilter.svelte | 32 ++++----------- 2 files changed, 48 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/document/TimelineControls.svelte diff --git a/frontend/src/lib/document/TimelineControls.svelte b/frontend/src/lib/document/TimelineControls.svelte new file mode 100644 index 00000000..f33f69f5 --- /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 index 62d7f4d5..322dd3c3 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -12,6 +12,7 @@ import { getLocale } from '$lib/paraglide/runtime'; import TimelineBars from '$lib/document/TimelineBars.svelte'; import TimelineYAxis from '$lib/document/TimelineYAxis.svelte'; import TimelineXAxis from '$lib/document/TimelineXAxis.svelte'; +import TimelineControls from '$lib/document/TimelineControls.svelte'; import type { components } from '$lib/generated/api'; type MonthBucket = components['schemas']['MonthBucket']; @@ -268,30 +269,11 @@ const dragLiveMessage = $derived.by(() => { {dragLiveMessage} -
- {#if isZoomed} - - {/if} - {#if hasSelection} - - {/if} -
+ {/if} -- 2.49.1 From 360db1ae3359a06271d4e58e91349f4877d5d6bf Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:49:24 +0200 Subject: [PATCH 32/52] chore(documents): drop V61 timeline density index migration (#385) The index was added in anticipation of a SQL GROUP BY aggregation, but DocumentService.getDensity aggregates in memory via findAll(spec).stream(). The index is never touched by the current query plan. Per Markus's round-2 review: drop the unused migration to avoid mismatched rationale-vs-implementation debt. Revisit when the archive crosses 50k rows (TODO already in getDensity Javadoc). Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V61__add_document_date_index.sql | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 backend/src/main/resources/db/migration/V61__add_document_date_index.sql diff --git a/backend/src/main/resources/db/migration/V61__add_document_date_index.sql b/backend/src/main/resources/db/migration/V61__add_document_date_index.sql deleted file mode 100644 index cba62619..00000000 --- a/backend/src/main/resources/db/migration/V61__add_document_date_index.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Index on documents.meta_date for the timeline density aggregation (issue #385). --- The new GET /api/documents/density endpoint does GROUP BY date_trunc('month', meta_date) --- across the full table; an index keeps the aggregation cheap as the archive grows. --- Cheap to add at any size and removes the future-investigation tax. --- --- Note: the entity field is `documentDate` but it's mapped to the PostgreSQL column --- `meta_date` (see Document.java @Column(name = "meta_date")). - -CREATE INDEX IF NOT EXISTS idx_documents_meta_date ON documents (meta_date); -- 2.49.1 From 47841b91106d75ac7378b472ad1dce944c490441 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:51:21 +0200 Subject: [PATCH 33/52] refactor(documents): YearMonth.from(d).toString() for density key (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YearMonth.from(d).toString() emits the same canonical YYYY-MM string as the previous String.format("%04d-%02d", …) call but reads as a single intent-revealing expression. Existing assertions on "1915-08", "1916-01", … pin the output format unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../org/raddatz/familienarchiv/document/DocumentService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bb2c9300..3399a334 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; @@ -171,8 +172,7 @@ public class DocumentService { Map counts = new java.util.TreeMap<>(); for (LocalDate d : dates) { - String month = String.format("%04d-%02d", d.getYear(), d.getMonthValue()); - counts.merge(month, 1, Integer::sum); + counts.merge(YearMonth.from(d).toString(), 1, Integer::sum); } List buckets = counts.entrySet().stream() .map(e -> new MonthBucket(e.getKey(), e.getValue())) -- 2.49.1 From ffe617dba83e6e3f6976f0d0a895bf3fcf793d4a Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:52:03 +0200 Subject: [PATCH 34/52] docs(documents): note nullable minDate/maxDate on DocumentDensityResult (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-result case returns null for both bounds, which the TS codegen surfaces as optional. Future contributors should not "fix" the missing @Schema(REQUIRED) — it is deliberate. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/document/DocumentDensityResult.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java index 5c246113..521feca5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentDensityResult.java @@ -5,6 +5,15 @@ 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, -- 2.49.1 From c9be6cc165959a14c02d30e6e88eb9a188d39a61 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:53:48 +0200 Subject: [PATCH 35/52] test(documents): @Transactional rollback in DocumentDensityIntegrationTest (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces @DirtiesContext(AFTER_EACH_TEST_METHOD), which restarted the full Spring context per test (≈10–15s × 7), with @Transactional rollback. Each test still sees a clean slate via the spring-test default rollback, but the context is shared across the class. Wall time for this class dropped from 35s to 17.87s in local runs. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentDensityIntegrationTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java index 09e0f24a..649938e0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentDensityIntegrationTest.java @@ -11,9 +11,9 @@ 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.annotation.DirtiesContext; 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; @@ -34,7 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@Transactional class DocumentDensityIntegrationTest { @MockitoBean S3Client s3Client; -- 2.49.1 From 2e9ce8e1da79b85de5b0973dc8953d45cec684b0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:55:04 +0200 Subject: [PATCH 36/52] fix(documents): surface timeline density fetch failures via console.warn (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a 5xx, network blip, or JSON parse error all collapsed into the same silent "no buckets" rendering. The widget still degrades gracefully — failure should not block the document list — but operators and Sentry now see the failure in browser devtools instead of having to reverse-engineer a missing chart. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/document/timeline.spec.ts | 23 ++++++++++++++++++++++ frontend/src/lib/document/timeline.ts | 8 ++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts index 212437a5..9deca472 100644 --- a/frontend/src/lib/document/timeline.spec.ts +++ b/frontend/src/lib/document/timeline.spec.ts @@ -285,6 +285,29 @@ describe('fetchDensity', () => { expect(result).toEqual({ density: [], minDate: null, maxDate: null }); }); + + it('emits console.warn with the status when the response is non-ok', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await fetchDensity(fetch, null, true); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('503'); + warn.mockRestore(); + }); + + it('emits console.warn with the caught error when fetch rejects', async () => { + const error = new TypeError('Network down'); + const fetch = vi.fn().mockRejectedValue(error); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await fetchDensity(fetch, null, true); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]).toContain(error); + warn.mockRestore(); + }); }); describe('tickIndicesFor', () => { diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts index 5919faf7..81d5c90e 100644 --- a/frontend/src/lib/document/timeline.ts +++ b/frontend/src/lib/document/timeline.ts @@ -208,14 +208,18 @@ export async function fetchDensity( try { const response = await fetch(buildDensityUrl(filters)); - if (!response.ok) return EMPTY; + if (!response.ok) { + console.warn(`[timeline] density fetch responded with ${response.status}`); + return EMPTY; + } const body = (await response.json()) as DocumentDensityResult; return { density: body.buckets, minDate: body.minDate ?? null, maxDate: body.maxDate ?? null }; - } catch { + } catch (error) { + console.warn('[timeline] density fetch failed', error); return EMPTY; } } -- 2.49.1 From 3b6b117c752a2c5832c8790ef2076152a895faa5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:56:58 +0200 Subject: [PATCH 37/52] fix(documents): cleanup timeline drag listeners on unmount (#385) Pointerdown attaches three document-level listeners. Without an explicit teardown, an unmount mid-drag (route change, view toggle, viewport drops below lg) left them attached and they kept writing to torn-down state cells. Wrap the cleanup in $effect's return, which Svelte 5 invokes on unmount. The listener-removal regression test pins this so the bug cannot come back silently. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/document/TimelineDensityFilter.svelte | 7 ++++++ .../TimelineDensityFilter.svelte.spec.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 322dd3c3..5c8e99b6 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -160,6 +160,13 @@ function cleanupDragListeners() { document.removeEventListener('pointercancel', handleDocumentCancel); } +// Strip any in-flight document listeners if the component unmounts mid-drag +// (route change, view toggle, breakpoint drop). Without this they survive on +// document and keep writing to torn-down state cells. +$effect(() => { + return cleanupDragListeners; +}); + function finalizeDrag() { if (dragStartIndex === null || dragEndIndex === null) return; const start = dragStartIndex; diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts index 0be61ffa..cff9180e 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -389,6 +389,30 @@ describe('TimelineDensityFilter — aria-live during drag', () => { }); }); +describe('TimelineDensityFilter — listener cleanup on unmount', () => { + it('removes document pointer listeners when unmounted mid-drag', async () => { + const removed: string[] = []; + const realRemove = document.removeEventListener.bind(document); + const removeSpy = vi + .spyOn(document, 'removeEventListener') + .mockImplementation((type: string, listener, options) => { + removed.push(type); + return realRemove(type, listener as EventListener, options); + }); + + render(TimelineDensityFilter, makeProps()); + const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement; + bar.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 })); + + cleanup(); + + expect(removed).toContain('pointermove'); + expect(removed).toContain('pointerup'); + expect(removed).toContain('pointercancel'); + removeSpy.mockRestore(); + }); +}); + describe('TimelineDensityFilter — drag-to-select-range', () => { function pointerDown(el: HTMLElement) { const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }); -- 2.49.1 From 153752a901e2e43eaf81dc1af3e7b012b71032cf Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 10:58:59 +0200 Subject: [PATCH 38/52] fix(documents): bump timeline control buttons to 44x44 (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCAG 2.5.8 (target size, AA) requires 44×44 minimum, and the project's senior persona makes that a hard floor on desktop too. Reset-zoom: h-6 → h-11 + min-w-[44px] + px-3. Clear-selection: h-6 w-6 → h-11 w-11. Two regression tests on the TimelineDensityFilter spec assert the sized classes so a future shrink can't slip through silently. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/document/TimelineControls.svelte | 4 ++-- .../TimelineDensityFilter.svelte.spec.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/document/TimelineControls.svelte b/frontend/src/lib/document/TimelineControls.svelte index f33f69f5..401d9098 100644 --- a/frontend/src/lib/document/TimelineControls.svelte +++ b/frontend/src/lib/document/TimelineControls.svelte @@ -22,7 +22,7 @@ let { aria-label={m.timeline_zoom_reset()} title={m.timeline_zoom_reset()} onclick={onresetzoom} - class="hover:text-ink-1 inline-flex h-6 items-center justify-center gap-1 rounded-sm px-2 text-xs text-ink-3 hover:bg-canvas" + class="hover:text-ink-1 inline-flex h-11 min-w-[44px] items-center justify-center gap-1 rounded-sm px-3 text-xs text-ink-3 hover:bg-canvas" > ↩ @@ -33,7 +33,7 @@ let { data-testid="timeline-clear" aria-label={m.timeline_clear_selection()} onclick={onclearselection} - class="hover:text-ink-1 inline-flex h-6 w-6 items-center justify-center rounded-full text-ink-3 hover:bg-canvas" + class="hover:text-ink-1 inline-flex h-11 w-11 items-center justify-center rounded-full text-ink-3 hover:bg-canvas" > × diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts index cff9180e..aaa7de58 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -308,6 +308,24 @@ describe('TimelineDensityFilter — zoom', () => { }); }); +describe('TimelineDensityFilter — touch targets', () => { + it('reset-zoom button is at least 44×44 (WCAG 2.5.8)', async () => { + render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' })); + const resetBtn = document.querySelector('[data-testid="timeline-zoom-reset"]') as HTMLElement; + expect(resetBtn.classList.contains('h-11')).toBe(true); + expect(resetBtn.className).toMatch(/min-w-\[44px\]/); + expect(resetBtn.className).not.toMatch(/(?:^|\s)h-6(?:$|\s)/); + }); + + it('clear-selection button is at least 44×44 (WCAG 2.5.8)', async () => { + render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' })); + const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement; + expect(clearBtn.classList.contains('h-11')).toBe(true); + expect(clearBtn.classList.contains('w-11')).toBe(true); + expect(clearBtn.className).not.toMatch(/(?:^|\s)h-6(?:$|\s)/); + }); +}); + describe('TimelineDensityFilter — accessibility', () => { it('Y-axis labels meet the 12px minimum font floor (Tailwind text-xs)', async () => { render(TimelineDensityFilter, makeProps()); -- 2.49.1 From 48da819a54752862468594dd9564445d4d93f434 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 11:00:15 +0200 Subject: [PATCH 39/52] feat(documents): focus-visible ring on timeline bar buttons (#385) Bar buttons rendered with bg-transparent + p-0 fell back to the default browser outline, which is invisible against bg-surface for keyboard users. Adds the project-standard focus ring (ring-2/brand-navy/offset-2) so the focused bar reads as focused. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/document/TimelineBars.svelte | 2 +- .../src/lib/document/TimelineDensityFilter.svelte.spec.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/document/TimelineBars.svelte b/frontend/src/lib/document/TimelineBars.svelte index 6e288820..56bc96ea 100644 --- a/frontend/src/lib/document/TimelineBars.svelte +++ b/frontend/src/lib/document/TimelineBars.svelte @@ -59,7 +59,7 @@ function barHeight(count: number): number { onpointerdown={(e) => onbarpointerdown(e, i)} onpointerenter={() => onbarpointerenter(i)} onclick={() => onbarclick(i)} - class="bar group flex h-full min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors" + class="bar group flex h-full min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none" class:selected={isSelected(bucket.month)} class:in-drag-preview={isInDragPreview(i)} > diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts index aaa7de58..c09c239c 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -324,6 +324,14 @@ describe('TimelineDensityFilter — touch targets', () => { expect(clearBtn.classList.contains('w-11')).toBe(true); expect(clearBtn.className).not.toMatch(/(?:^|\s)h-6(?:$|\s)/); }); + + it('bar buttons render a focus-visible ring so keyboard users can see focus', async () => { + render(TimelineDensityFilter, makeProps()); + const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement; + expect(bar.className).toMatch(/focus-visible:ring-2/); + expect(bar.className).toMatch(/focus-visible:ring-brand-navy/); + expect(bar.className).toMatch(/focus-visible:ring-offset-2/); + }); }); describe('TimelineDensityFilter — accessibility', () => { -- 2.49.1 From 1d6016cb19571e765672abd22d38b6623751d4d9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 11:03:19 +0200 Subject: [PATCH 40/52] fix(documents): pluralise timeline bar aria-label by count (#385) The flat "{count} Dokumente / documents / documentos" keys read as "1 Dokumente" / "1 documents" / "1 documentos" to a screen reader when only one document falls in the month bucket. Splits each locale into _singular + _plural keys and picks the form by count in TimelineBars, mirroring the existing upload_banner_singular / _plural pattern in this project. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 3 +- frontend/messages/en.json | 3 +- frontend/messages/es.json | 3 +- frontend/src/lib/document/TimelineBars.svelte | 10 ++++--- .../TimelineDensityFilter.svelte.spec.ts | 30 +++++++++++++++++++ 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 4c4caeb3..e2a06192 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1050,6 +1050,7 @@ "timeline_aria_label": "Zeitachse Dokumentdichte", "timeline_clear_selection": "Auswahl zurücksetzen", "timeline_zoom_reset": "Zurück zur Übersicht", - "timeline_bar_aria": "{when}, {count} Dokumente", + "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 536ca0fb..10c3bf2e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1050,6 +1050,7 @@ "timeline_aria_label": "Document density timeline", "timeline_clear_selection": "Clear selection", "timeline_zoom_reset": "Reset zoom", - "timeline_bar_aria": "{when}, {count} documents", + "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 f4677d90..ca2439d9 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1050,6 +1050,7 @@ "timeline_aria_label": "Cronología de densidad de documentos", "timeline_clear_selection": "Borrar selección", "timeline_zoom_reset": "Restablecer zoom", - "timeline_bar_aria": "{when}, {count} documentos", + "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 index 56bc96ea..cac4c180 100644 --- a/frontend/src/lib/document/TimelineBars.svelte +++ b/frontend/src/lib/document/TimelineBars.svelte @@ -51,10 +51,12 @@ function barHeight(count: number): number {