feat(documents): timeline date-range filter with density bars (#385) #478
@@ -0,0 +1,23 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The non-date filters honoured by {@link DocumentService#getDensity(DensityFilters)}.
|
||||
* Date bounds (from/to) are deliberately excluded — see the service Javadoc for why.
|
||||
*
|
||||
* Kept as a record so the seven values are passed as one named bundle instead of a
|
||||
* positional argument list where two UUIDs (sender vs. receiver) can be swapped by
|
||||
* accident at the call site.
|
||||
*/
|
||||
public record DensityFilters(
|
||||
String text,
|
||||
UUID sender,
|
||||
UUID receiver,
|
||||
List<String> tags,
|
||||
String tagQ,
|
||||
DocumentStatus status,
|
||||
TagOperator tagOperator) {}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -388,6 +390,23 @@ public class DocumentController {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<DocumentDensityResult> density(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
|
||||
.body(result);
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
public record TrainingLabelRequest(String label, boolean enrolled) {}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of the timeline density aggregation.
|
||||
*
|
||||
* <p>{@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<MonthBucket> buckets,
|
||||
LocalDate minDate,
|
||||
LocalDate maxDate
|
||||
) {
|
||||
/** The "no documents match the filter" result, with no buckets and null date bounds. */
|
||||
public static DocumentDensityResult empty() {
|
||||
return new DocumentDensityResult(List.of(), null, null);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
@@ -125,6 +126,74 @@ public class DocumentService {
|
||||
return titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-month document counts for the timeline density widget (issue #385).
|
||||
*
|
||||
* <p>Filter-reactive: the chart recomputes when other filters (sender,
|
||||
* receiver, tag, q, status) change so it always matches the list it sits
|
||||
* above. Date bounds (`from`/`to`) are deliberately omitted — the chart is
|
||||
* the surface for picking those, so it must always span the broader space
|
||||
* the user is selecting within.
|
||||
*
|
||||
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
||||
* because the existing {@link Specification} predicates compose easily
|
||||
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
||||
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
|
||||
* controller layer absorbs repeated browse loads.
|
||||
*
|
||||
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
|
||||
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
|
||||
* 'YYYY-MM')) and accept that the criteria/specification surface needs a
|
||||
* parallel native-query path.
|
||||
*/
|
||||
public DocumentDensityResult getDensity(DensityFilters filters) {
|
||||
List<UUID> ftsIds = resolveFtsIds(filters.text());
|
||||
if (ftsIds != null && ftsIds.isEmpty()) {
|
||||
return DocumentDensityResult.empty();
|
||||
}
|
||||
List<LocalDate> dates = loadFilteredDates(filters, ftsIds);
|
||||
return aggregateByMonth(dates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the FTS-ranked document IDs when {@code text} is non-blank, or {@code null}
|
||||
* when no full-text query is active. An empty list means the FTS query ran but
|
||||
* matched zero documents — the caller short-circuits on that signal.
|
||||
*/
|
||||
private List<UUID> resolveFtsIds(String text) {
|
||||
if (!StringUtils.hasText(text)) return null;
|
||||
return documentRepository.findRankedIdsByFts(text);
|
||||
}
|
||||
|
||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
||||
boolean hasFts = ftsIds != null;
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasFts, ftsIds, null, null,
|
||||
filters.sender(), filters.receiver(),
|
||||
filters.tags(), filters.tagQ(),
|
||||
filters.status(), filters.tagOperator());
|
||||
return documentRepository.findAll(spec).stream()
|
||||
.map(Document::getDocumentDate)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Buckets {@code dates} into one {@link MonthBucket} per YYYY-MM and computes min/max. */
|
||||
private DocumentDensityResult aggregateByMonth(List<LocalDate> dates) {
|
||||
if (dates.isEmpty()) return DocumentDensityResult.empty();
|
||||
Map<String, Integer> counts = new java.util.TreeMap<>();
|
||||
for (LocalDate d : dates) {
|
||||
counts.merge(YearMonth.from(d).toString(), 1, Integer::sum);
|
||||
}
|
||||
List<MonthBucket> buckets = counts.entrySet().stream()
|
||||
.map(e -> new MonthBucket(e.getKey(), e.getValue()))
|
||||
.toList();
|
||||
LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null);
|
||||
LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null);
|
||||
return new DocumentDensityResult(buckets, minDate, maxDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch.
|
||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -1240,4 +1241,100 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.errors[0].message").value(
|
||||
org.hamcrest.Matchers.containsString("not found")));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/density ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void density_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_returns200_withResultBody_whenAuthenticated() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(
|
||||
List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)),
|
||||
java.time.LocalDate.of(1915, 8, 3),
|
||||
java.time.LocalDate.of(1915, 9, 1)));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.buckets").isArray())
|
||||
.andExpect(jsonPath("$.buckets[0].month").value("1915-08"))
|
||||
.andExpect(jsonPath("$.buckets[0].count").value(2))
|
||||
.andExpect(jsonPath("$.minDate").value("1915-08-03"))
|
||||
.andExpect(jsonPath("$.maxDate").value("1915-09-01"));
|
||||
}
|
||||
|
||||
// Pins produces=APPLICATION_JSON_VALUE on the density mapping so the OpenAPI/TypeScript
|
||||
// codegen records application/json instead of the wildcard. Without produces= the
|
||||
// request-mapping accepts any Accept header and the OpenAPI emit falls back to the
|
||||
// wildcard. Sending an Accept header that JSON cannot satisfy must NOT return 200 —
|
||||
// Spring rejects with 406 (HttpMediaTypeNotAcceptableException), which our
|
||||
// GlobalExceptionHandler may surface as 400. Either way it proves the route is
|
||||
// locked to JSON.
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_declaresApplicationJsonContentType() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.accept(MediaType.APPLICATION_XML))
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_emitsPrivateCacheControlHeader() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("max-age=300")))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("private")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_forwardsSenderAndTagFilters() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
UUID senderId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.param("senderId", senderId.toString())
|
||||
.param("tag", "Familie")
|
||||
.param("tag", "Urlaub")
|
||||
.param("tagOp", "OR"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getDensity(eq(new DensityFilters(
|
||||
null, senderId, null,
|
||||
List.of("Familie", "Urlaub"),
|
||||
null, null,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.OR)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_forwardsStatusAndQueryText() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.param("q", "Brief")
|
||||
.param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getDensity(eq(new DensityFilters(
|
||||
"Brief", null, null, null, null,
|
||||
DocumentStatus.REVIEWED,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end test for the filter-reactive density aggregation.
|
||||
* Density bars must recompute as the user changes other filters (sender, tag,
|
||||
* status, …). The endpoint deliberately does NOT honour `from`/`to` — the chart
|
||||
* is the surface for picking those, so it must always span the broader space
|
||||
* the user is selecting within.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class DocumentDensityIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
|
||||
private Person hans;
|
||||
private Person anna;
|
||||
private Tag familieTag;
|
||||
private Tag urlaubTag;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
anna = personRepository.save(Person.builder().firstName("Anna").lastName("Weber").build());
|
||||
familieTag = tagRepository.save(Tag.builder().name("Familie").build());
|
||||
urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build());
|
||||
}
|
||||
|
||||
private static DensityFilters noFilters() {
|
||||
return new DensityFilters(null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsAllMonths_whenNoFiltersApplied() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of());
|
||||
save("b", LocalDate.of(1915, 8, 17), null, Set.of());
|
||||
save("c", LocalDate.of(1915, 9, 1), null, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(noFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1915-09");
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
|
||||
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersBySender() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
|
||||
save("b", LocalDate.of(1916, 1, 4), hans, Set.of());
|
||||
save("c", LocalDate.of(1920, 5, 1), anna, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, hans.getId(), null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1916-01");
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1916, 1, 4));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersByTag() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of(familieTag));
|
||||
save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag));
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, null, null, List.of("Familie"), null, null, TagOperator.AND));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_combinesSenderAndTag() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of(familieTag));
|
||||
save("b", LocalDate.of(1916, 1, 4), hans, Set.of(urlaubTag));
|
||||
save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag));
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersByStatus() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of(), DocumentStatus.UPLOADED);
|
||||
save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER);
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, null, null, null, null, DocumentStatus.UPLOADED, null));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsEmpty_whenNoDocumentsMatch() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, anna.getId(), null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
assertThat(result.minDate()).isNull();
|
||||
assertThat(result.maxDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_excludesDocumentsWithNullDate() {
|
||||
save("dated", LocalDate.of(1915, 8, 3), null, Set.of());
|
||||
save("undated", null, null, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(noFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
|
||||
}
|
||||
|
||||
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags) {
|
||||
save(suffix, date, sender, tags, DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags, DocumentStatus status) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Doc " + suffix)
|
||||
.originalFilename("doc-" + suffix + "-" + UUID.randomUUID() + ".pdf")
|
||||
.status(status)
|
||||
.documentDate(date)
|
||||
.sender(sender)
|
||||
.tags(new HashSet<>(tags))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -2321,4 +2322,61 @@ class DocumentServiceTest {
|
||||
assertThat(documentService.save(doc)).isEqualTo(doc);
|
||||
verify(documentRepository).save(doc);
|
||||
}
|
||||
|
||||
// ─── getDensity ────────────────────────────────────────────────────────────
|
||||
|
||||
private static DensityFilters anyFilters() {
|
||||
return new DensityFilters(null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsEmptyResult_whenNoDocumentsMatch() {
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
assertThat(result.minDate()).isNull();
|
||||
assertThat(result.maxDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_groupsMatchingDocumentsByMonth() {
|
||||
Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
|
||||
Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build();
|
||||
Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build();
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c));
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1915-09");
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
|
||||
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_excludesDocumentsWithNullDate() {
|
||||
Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
|
||||
Document undated = Document.builder().documentDate(null).build();
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated));
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
|
||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters("xyz", null, null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.")
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
|
||||
|
||||
@@ -8,6 +8,8 @@ Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
||||
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||
@@ -21,6 +23,9 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
|
||||
Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
||||
Rel(homePage, timelineFilter, "Mounts above the result list")
|
||||
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
Rel(docNew, backend, "GET /api/persons, POST /api/documents", "HTTP / JSON + Multipart")
|
||||
|
||||
@@ -1045,5 +1045,12 @@
|
||||
"relation_form_year_placeholder": "z.B. 1920",
|
||||
|
||||
"person_relationships_heading": "Beziehungen",
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt."
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||
|
||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||
"timeline_clear_selection": "Auswahl zurücksetzen",
|
||||
"timeline_zoom_reset": "Zurück zur Übersicht",
|
||||
"timeline_bar_aria_singular": "{when}, 1 Dokument",
|
||||
"timeline_bar_aria_plural": "{when}, {count} Dokumente",
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt"
|
||||
}
|
||||
|
||||
@@ -1045,5 +1045,12 @@
|
||||
"relation_form_year_placeholder": "e.g. 1920",
|
||||
|
||||
"person_relationships_heading": "Relationships",
|
||||
"person_relationships_empty": "No relationships known yet."
|
||||
"person_relationships_empty": "No relationships known yet.",
|
||||
|
||||
"timeline_aria_label": "Document density timeline",
|
||||
"timeline_clear_selection": "Clear selection",
|
||||
"timeline_zoom_reset": "Reset zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 document",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documents",
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected"
|
||||
}
|
||||
|
||||
@@ -1045,5 +1045,12 @@
|
||||
"relation_form_year_placeholder": "ej. 1920",
|
||||
|
||||
"person_relationships_heading": "Relaciones",
|
||||
"person_relationships_empty": "Aún no se conocen relaciones."
|
||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||
|
||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||
"timeline_clear_selection": "Borrar selección",
|
||||
"timeline_zoom_reset": "Restablecer zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 documento",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documentos",
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado"
|
||||
}
|
||||
|
||||
128
frontend/src/lib/document/TimelineBars.svelte
Normal file
128
frontend/src/lib/document/TimelineBars.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { formatTickLabel } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
|
||||
|
||||
let {
|
||||
filled,
|
||||
maxCount,
|
||||
barAreaHeight,
|
||||
isSelected,
|
||||
isInDragPreview,
|
||||
isDragging,
|
||||
dragWindowLeftPct,
|
||||
dragWindowRightPct,
|
||||
rowEl = $bindable(),
|
||||
onbarpointerdown,
|
||||
onbarpointerenter,
|
||||
onbarclick
|
||||
}: {
|
||||
filled: MonthBucket[];
|
||||
maxCount: number;
|
||||
barAreaHeight: number;
|
||||
isSelected: (label: string) => boolean;
|
||||
isInDragPreview: (index: number) => boolean;
|
||||
isDragging: boolean;
|
||||
dragWindowLeftPct: number;
|
||||
dragWindowRightPct: number;
|
||||
rowEl?: HTMLDivElement;
|
||||
onbarpointerdown: (event: PointerEvent, index: number) => void;
|
||||
onbarpointerenter: (index: number) => void;
|
||||
onbarclick: (index: number) => void;
|
||||
} = $props();
|
||||
|
||||
function barHeight(count: number): number {
|
||||
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
|
||||
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * barAreaHeight);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={rowEl}
|
||||
class="relative flex items-end border-b border-line"
|
||||
style="height: {barAreaHeight}px;"
|
||||
>
|
||||
{#each filled as bucket, i (bucket.month)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-bar"
|
||||
aria-label={bucket.count === 1
|
||||
? m.timeline_bar_aria_singular({ when: formatTickLabel(bucket.month, getLocale()) })
|
||||
: m.timeline_bar_aria_plural({
|
||||
when: formatTickLabel(bucket.month, getLocale()),
|
||||
count: bucket.count
|
||||
})}
|
||||
aria-pressed={isSelected(bucket.month)}
|
||||
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 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)}
|
||||
>
|
||||
<span
|
||||
class="bar-fill block w-full rounded-t-[2px]"
|
||||
style="height: {barHeight(bucket.count)}px;"
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if isDragging}
|
||||
<div
|
||||
class="drag-window"
|
||||
data-testid="timeline-drag-window"
|
||||
style="left: {dragWindowLeftPct}%; right: {dragWindowRightPct}%;"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Timeline-specific tokens (--timeline-bar-idle, --timeline-bar-outside) live
|
||||
in layout.css next to the rest of the design tokens; this <style> only
|
||||
consumes them. */
|
||||
.bar .bar-fill {
|
||||
background-color: var(--timeline-bar-idle);
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar .bar-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bar.selected .bar-fill,
|
||||
.bar.in-drag-preview .bar-fill {
|
||||
background-color: var(--palette-mint, #a1dcd8);
|
||||
}
|
||||
|
||||
.bar.in-drag-preview .bar-fill {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Gate hover under (hover: hover) so emulated mouse events on touch devices
|
||||
don't leave a tapped bar stuck in :hover until the next tap elsewhere. */
|
||||
@media (hover: hover) {
|
||||
.bar:hover .bar-fill {
|
||||
background-color: var(--palette-mint, #a1dcd8);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
/* Graylog-style range selector window: left/right borders mark the dragged
|
||||
range, tinted body fills the area. pointer-events:none keeps the bars below
|
||||
reachable so pointermove still fires their pointerenter. */
|
||||
.drag-window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(161, 220, 216, 0.22);
|
||||
border-left: 2px solid var(--palette-mint, #a1dcd8);
|
||||
border-right: 2px solid var(--palette-mint, #a1dcd8);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/lib/document/TimelineControls.svelte
Normal file
41
frontend/src/lib/document/TimelineControls.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
isZoomed,
|
||||
hasSelection,
|
||||
onresetzoom,
|
||||
onclearselection
|
||||
}: {
|
||||
isZoomed: boolean;
|
||||
hasSelection: boolean;
|
||||
onresetzoom: () => void;
|
||||
onclearselection: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||
{#if isZoomed}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-zoom-reset"
|
||||
aria-label={m.timeline_zoom_reset()}
|
||||
title={m.timeline_zoom_reset()}
|
||||
onclick={onresetzoom}
|
||||
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 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasSelection}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-clear"
|
||||
aria-label={m.timeline_clear_selection()}
|
||||
onclick={onclearselection}
|
||||
class="hover:text-ink-1 inline-flex h-11 w-11 items-center justify-center rounded-full text-ink-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
204
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal file
204
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import {
|
||||
fillDensityGaps,
|
||||
aggregateToYears,
|
||||
clipBucketsToRange,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
formatTickLabel
|
||||
} from '$lib/document/timeline';
|
||||
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
|
||||
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'];
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
let {
|
||||
density,
|
||||
minDate,
|
||||
maxDate,
|
||||
from,
|
||||
to,
|
||||
zoomFrom = null,
|
||||
zoomTo = null,
|
||||
onchange,
|
||||
onzoomchange
|
||||
}: {
|
||||
density: MonthBucket[] | null;
|
||||
minDate: string | null;
|
||||
maxDate: string | null;
|
||||
from: string;
|
||||
to: string;
|
||||
zoomFrom?: string | null;
|
||||
zoomTo?: string | null;
|
||||
onchange: (event: SelectionEvent) => void;
|
||||
onzoomchange?: (event: ZoomEvent | null) => void;
|
||||
} = $props();
|
||||
|
||||
const monthBuckets = $derived.by(() => {
|
||||
if (density === null) return [];
|
||||
const full = fillDensityGaps(density, minDate, maxDate);
|
||||
return clipBucketsToRange(full, zoomFrom, zoomTo);
|
||||
});
|
||||
|
||||
const filled = $derived(
|
||||
monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
|
||||
);
|
||||
|
||||
const isZoomed = $derived(zoomFrom !== null && zoomTo !== null);
|
||||
|
||||
function resetZoom() {
|
||||
onzoomchange?.(null);
|
||||
}
|
||||
|
||||
const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
|
||||
|
||||
const hasSelection = $derived(from !== '' || to !== '');
|
||||
|
||||
let rowEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function clearSelection() {
|
||||
onchange({ from: '', to: '' });
|
||||
}
|
||||
|
||||
function isSelected(label: string): boolean {
|
||||
if (!hasSelection) return false;
|
||||
const labelFrom = selectionBoundaryFrom(label);
|
||||
return labelFrom >= from && labelFrom <= to;
|
||||
}
|
||||
|
||||
function isYearLabel(label: string): boolean {
|
||||
return label.length === 4;
|
||||
}
|
||||
|
||||
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;
|
||||
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)));
|
||||
}
|
||||
|
||||
const drag = createTimelineDrag({
|
||||
indexFromClientX,
|
||||
labelAt: (i) => filled[i]?.month,
|
||||
isYearLabel,
|
||||
emit: emitSelection
|
||||
});
|
||||
|
||||
// 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(() => drag.cleanup);
|
||||
|
||||
function isInDragPreview(index: number): boolean {
|
||||
if (!drag.isDragging) return false;
|
||||
if (drag.lowIndex === null || drag.highIndex === null) return false;
|
||||
return index >= drag.lowIndex && index <= drag.highIndex;
|
||||
}
|
||||
|
||||
const dragWindowLeftPct = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.lowIndex === null || filled.length === 0) return 0;
|
||||
return (drag.lowIndex / filled.length) * 100;
|
||||
});
|
||||
const dragWindowRightPct = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.highIndex === null || filled.length === 0) return 100;
|
||||
return ((filled.length - drag.highIndex - 1) / filled.length) * 100;
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
if (!drag.isDragging || drag.lowIndex === null || drag.highIndex === null) return '';
|
||||
const fromLabel = filled[drag.lowIndex]?.month;
|
||||
const toLabel = filled[drag.highIndex]?.month;
|
||||
if (!fromLabel || !toLabel) return '';
|
||||
return m.timeline_dragging_aria_live({
|
||||
from: formatTickLabel(fromLabel, getLocale()),
|
||||
to: formatTickLabel(toLabel, getLocale())
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if density !== null}
|
||||
<div
|
||||
data-testid="timeline-density-filter"
|
||||
role="group"
|
||||
aria-label={m.timeline_aria_label()}
|
||||
class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
|
||||
>
|
||||
<div class="flex">
|
||||
<TimelineYAxis maxCount={maxCount} barAreaHeight={BAR_AREA_HEIGHT} />
|
||||
|
||||
<div class="flex-1">
|
||||
<TimelineBars
|
||||
filled={filled}
|
||||
maxCount={maxCount}
|
||||
barAreaHeight={BAR_AREA_HEIGHT}
|
||||
isSelected={isSelected}
|
||||
isInDragPreview={isInDragPreview}
|
||||
isDragging={drag.isDragging}
|
||||
dragWindowLeftPct={dragWindowLeftPct}
|
||||
dragWindowRightPct={dragWindowRightPct}
|
||||
bind:rowEl={rowEl}
|
||||
onbarpointerdown={drag.pointerDown}
|
||||
onbarpointerenter={drag.pointerEnter}
|
||||
onbarclick={drag.click}
|
||||
/>
|
||||
|
||||
<TimelineXAxis filled={filled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sr-only" aria-live="polite" data-testid="timeline-aria-live">
|
||||
{dragLiveMessage}
|
||||
</div>
|
||||
|
||||
<TimelineControls
|
||||
isZoomed={isZoomed}
|
||||
hasSelection={hasSelection}
|
||||
onresetzoom={resetZoom}
|
||||
onclearselection={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
659
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal file
659
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
|
||||
import { formatTickLabel } from './timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const NOOP = () => undefined;
|
||||
|
||||
function makeProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
] satisfies MonthBucket[],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
from: '',
|
||||
to: '',
|
||||
onchange: NOOP,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimelineDensityFilter — visibility', () => {
|
||||
it('renders nothing when density is null', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ density: null, minDate: null, maxDate: null }));
|
||||
expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the widget when density is populated', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes an accessible group label on the widget', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const widget = document.querySelector('[data-testid="timeline-density-filter"]') as HTMLElement;
|
||||
expect(widget.getAttribute('role')).toBe('group');
|
||||
expect(widget.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — axes', () => {
|
||||
it('renders a Y-axis showing the maximum bar count and zero', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 12 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(yAxis).not.toBeNull();
|
||||
expect(yAxis.textContent).toContain('12');
|
||||
expect(yAxis.textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('renders X-axis ticks at January boundaries for long month ranges', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let m = 8; m <= 12; m++)
|
||||
buckets.push({ month: `1914-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 12; m++)
|
||||
buckets.push({ month: `1915-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 2; m++)
|
||||
buckets.push({ month: `1916-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1914-08-01', maxDate: '1916-02-29' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
expect(ticks.length).toBe(2);
|
||||
expect(Array.from(ticks).map((t) => t.textContent?.trim())).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('1915'), expect.stringContaining('1916')])
|
||||
);
|
||||
});
|
||||
|
||||
it('renders X-axis ticks for year-aggregated bars (every 10 years for ~50yr range)', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1949; year++) {
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
buckets.push({ month: `${year}-${String(month).padStart(2, '0')}`, count: 1 });
|
||||
}
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1949-12-31' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
const labels = Array.from(ticks).map((t) => t.textContent?.trim());
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — bars', () => {
|
||||
it('renders one bar per month within the range, including zero-count gaps', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
|
||||
it('zero-count months get the minimum visible bar height of 2px', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 4 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-09-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"] .bar-fill'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
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<HTMLElement>;
|
||||
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 <button> with aria-label (Nora a11y review)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.tagName).toBe('BUTTON');
|
||||
expect(clearBtn.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — year-granularity fallback', () => {
|
||||
it('collapses to year buckets when the month sequence exceeds the limit', async () => {
|
||||
// 21 years × 12 months = 252 entries — above the 240 month limit.
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: year - 1899 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1920-12-31' })
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(21);
|
||||
const firstLabel = bars[0].getAttribute('aria-label') ?? '';
|
||||
// Localised, not the raw machine string "1900 · 1".
|
||||
expect(firstLabel).not.toMatch(/^\d{4} · \d+$/);
|
||||
expect(firstLabel).toContain('1900');
|
||||
expect(firstLabel).toContain('1');
|
||||
});
|
||||
|
||||
it('clicking a year bar zooms into that year (filter + zoom atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: 5 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: buckets,
|
||||
minDate: '1900-01-01',
|
||||
maxDate: '1920-12-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
bars[5].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1905-01-01',
|
||||
to: '1905-12-31',
|
||||
zoomFrom: '1905-01-01',
|
||||
zoomTo: '1905-12-31'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — zoom', () => {
|
||||
it('does not show the zoom-in button (drag replaces it as the zoom gesture)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
const firstLabel = bars[0].getAttribute('aria-label') ?? '';
|
||||
const lastLabel = bars[2].getAttribute('aria-label') ?? '';
|
||||
// Localised — must NOT contain the raw "YYYY-MM" machine string.
|
||||
expect(firstLabel).not.toMatch(/\d{4}-\d{2}/);
|
||||
expect(lastLabel).not.toMatch(/\d{4}-\d{2}/);
|
||||
expect(firstLabel).toContain(formatTickLabel('1910-06', getLocale()));
|
||||
expect(lastLabel).toContain(formatTickLabel('1910-08', getLocale()));
|
||||
});
|
||||
});
|
||||
|
||||
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)/);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
it('reset-zoom button renders the same focus-visible ring as the bars', 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.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('clear-selection button renders the same focus-visible ring as the bars', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('bar hover style is gated by @media (hover: hover) to avoid touch-device hover-stick', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const cssText = Array.from(document.styleSheets)
|
||||
.flatMap((sheet) => {
|
||||
try {
|
||||
return Array.from(sheet.cssRules);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n');
|
||||
expect(cssText).toMatch(/@media \(hover: hover\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — accessibility', () => {
|
||||
it('Y-axis labels meet the 12px minimum font floor (Tailwind text-xs)', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(yAxis.classList.contains('text-xs')).toBe(true);
|
||||
expect(yAxis.className).not.toMatch(/text-\[10px\]/);
|
||||
});
|
||||
|
||||
it('X-axis row uses text-xs and h-4 to fit the 12px line height', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const xAxis = document.querySelector('[data-testid="timeline-x-axis"]') as HTMLElement;
|
||||
expect(xAxis.classList.contains('text-xs')).toBe(true);
|
||||
expect(xAxis.classList.contains('h-4')).toBe(true);
|
||||
expect(xAxis.className).not.toMatch(/text-\[10px\]/);
|
||||
});
|
||||
|
||||
it('bar aria-label uses the singular noun form when count is 1', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 1 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
// "documents", "Dokumente", "documentos" are the plural-only forms in our 3 locales.
|
||||
expect(label).not.toMatch(/\b(?:documents|Dokumente|documentos)\b/);
|
||||
expect(label).toMatch(/\b1 (?:document|Dokument|documento)\b/);
|
||||
});
|
||||
|
||||
it('bar aria-label uses the plural noun form when count is not 1', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 5 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
expect(label).toMatch(/\b5 (?:documents|Dokumente|documentos)\b/);
|
||||
});
|
||||
|
||||
it('bar aria-label is built from a localised template, never the raw YYYY-MM', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 5 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
expect(label).not.toMatch(/1915-08/);
|
||||
expect(label).toContain(formatTickLabel('1915-08', getLocale()));
|
||||
expect(label).toContain('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — aria-live during drag', () => {
|
||||
function pointerDown(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
function pointerEnter(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
|
||||
}
|
||||
function pointerUp(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
|
||||
it('renders a polite aria-live region whose text reflects the dragged range', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const live = document.querySelector('[data-testid="timeline-aria-live"]') as HTMLElement;
|
||||
expect(live).not.toBeNull();
|
||||
expect(live.getAttribute('aria-live')).toBe('polite');
|
||||
expect(live.textContent?.trim() ?? '').toBe('');
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
const text = live.textContent ?? '';
|
||||
expect(text).toContain(formatTickLabel('1915-08', getLocale()));
|
||||
expect(text).toContain(formatTickLabel('1915-10', getLocale()));
|
||||
|
||||
pointerUp(bars[2]);
|
||||
await tick();
|
||||
expect(live.textContent?.trim() ?? '').toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
// jsdom-style stub for setPointerCapture (the real DOM has it but vitest-browser
|
||||
// uses Playwright-driven Chromium so it works natively too).
|
||||
el.dispatchEvent(event);
|
||||
}
|
||||
function pointerEnter(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
|
||||
}
|
||||
function pointerUp(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
|
||||
it('dragging from bar A to bar B emits a single onchange with filter + zoom (atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
pointerUp(bars[2]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('dragging from a later bar to an earlier bar still emits ascending boundaries', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[2]);
|
||||
pointerEnter(bars[0]);
|
||||
pointerUp(bars[0]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('pressing+releasing on the same bar selects that single month without zoom', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ onchange }));
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[1]);
|
||||
pointerUp(bars[1]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-09-01', to: '1915-09-30' });
|
||||
});
|
||||
|
||||
it('renders a drag-window overlay between drag start and current position', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-11', count: 4 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-11-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
const win = document.querySelector('[data-testid="timeline-drag-window"]') as HTMLElement;
|
||||
expect(win).not.toBeNull();
|
||||
// 4 bars total, drag covers indices 0..2 → left 0%, right 25%.
|
||||
expect(win.style.left).toBe('0%');
|
||||
expect(win.style.right).toBe('25%');
|
||||
|
||||
pointerUp(bars[2]);
|
||||
await tick();
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks bars in the active drag range with the in-drag-preview class', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-11', count: 4 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-11-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
expect(bars[0].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[1].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[2].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[3].classList.contains('in-drag-preview')).toBe(false);
|
||||
|
||||
pointerUp(bars[2]);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/document/TimelineXAxis.svelte
Normal file
40
frontend/src/lib/document/TimelineXAxis.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
let {
|
||||
filled
|
||||
}: {
|
||||
filled: MonthBucket[];
|
||||
} = $props();
|
||||
|
||||
const tickIndices = $derived(tickIndicesFor(filled));
|
||||
|
||||
// When all visible buckets share a year, the X-axis omits the year so a
|
||||
// 12-month zoom reads as "Jan Feb Mär…" without repetition.
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative mt-1 h-4 font-sans text-xs leading-none text-ink-3"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-x-axis"
|
||||
>
|
||||
{#each tickIndices as idx (filled[idx]?.month)}
|
||||
{@const tickLeftPct = ((idx + 0.5) / filled.length) * 100}
|
||||
<span
|
||||
class="absolute -translate-x-1/2 whitespace-nowrap"
|
||||
data-testid="timeline-x-tick"
|
||||
style="left: {tickLeftPct}%;"
|
||||
>
|
||||
{formatTickLabel(filled[idx].month, getLocale(), omitTickYear)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
19
frontend/src/lib/document/TimelineYAxis.svelte
Normal file
19
frontend/src/lib/document/TimelineYAxis.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
maxCount,
|
||||
barAreaHeight
|
||||
}: {
|
||||
maxCount: number;
|
||||
barAreaHeight: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-between pr-1.5 text-right font-sans text-xs leading-none text-ink-3"
|
||||
style="height: {barAreaHeight}px;"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-y-axis"
|
||||
>
|
||||
<span>{maxCount}</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
392
frontend/src/lib/document/timeline.spec.ts
Normal file
392
frontend/src/lib/document/timeline.spec.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
monthBoundaryFrom,
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
fetchDensity,
|
||||
buildDensityUrl,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} 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']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDensityUrl', () => {
|
||||
it('returns the bare endpoint when no filters provided', () => {
|
||||
expect(buildDensityUrl()).toBe('/api/documents/density');
|
||||
});
|
||||
|
||||
it('forwards single-value filters as query params', () => {
|
||||
expect(buildDensityUrl({ q: 'Brief', senderId: 's-1' })).toBe(
|
||||
'/api/documents/density?q=Brief&senderId=s-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('repeats the tag param for multi-value tag filters', () => {
|
||||
const url = buildDensityUrl({ tags: ['Familie', 'Urlaub'], tagOp: 'OR' });
|
||||
expect(url).toContain('tag=Familie');
|
||||
expect(url).toContain('tag=Urlaub');
|
||||
expect(url).toContain('tagOp=OR');
|
||||
});
|
||||
|
||||
it('omits tagOp when it is AND (default on backend)', () => {
|
||||
const url = buildDensityUrl({ tags: ['Familie'], tagOp: 'AND' });
|
||||
expect(url).not.toContain('tagOp=');
|
||||
});
|
||||
|
||||
it('does not forward from/to even if a caller mistakenly adds them', () => {
|
||||
// Intentional: density is the surface for picking from/to, so it must always
|
||||
// span the broader space the user is selecting within.
|
||||
// @ts-expect-error - from/to are explicitly absent from DensityFilters
|
||||
const url = buildDensityUrl({ q: 'Brief', from: '1915-01-01', to: '1916-12-31' });
|
||||
expect(url).not.toContain('from=');
|
||||
expect(url).not.toContain('to=');
|
||||
});
|
||||
});
|
||||
|
||||
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('forwards active filters as query params', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ buckets: [], minDate: null, maxDate: null })
|
||||
});
|
||||
|
||||
await fetchDensity(fetch, null, true, { senderId: 's-1', tags: ['Familie'] });
|
||||
|
||||
const calledWith = fetch.mock.calls[0][0] as string;
|
||||
expect(calledWith).toContain('senderId=s-1');
|
||||
expect(calledWith).toContain('tag=Familie');
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
231
frontend/src/lib/document/timeline.ts
Normal file
231
frontend/src/lib/document/timeline.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
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`;
|
||||
}
|
||||
|
||||
export function monthBoundaryTo(yearMonth: string): string {
|
||||
const [year, month] = yearMonth.split('-').map(Number);
|
||||
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
|
||||
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
|
||||
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
|
||||
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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
|
||||
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
|
||||
*/
|
||||
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
|
||||
* long for month-granular bars to render at a clickable size.
|
||||
*/
|
||||
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const b of buckets) {
|
||||
const year = b.month.slice(0, 4);
|
||||
totals.set(year, (totals.get(year) ?? 0) + b.count);
|
||||
}
|
||||
return Array.from(totals.entries())
|
||||
.map(([year, count]) => ({ month: year, count }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
|
||||
* (year) and return the matching LocalDate string.
|
||||
*/
|
||||
export function selectionBoundaryFrom(label: string): string {
|
||||
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
|
||||
}
|
||||
|
||||
export function selectionBoundaryTo(label: string): string {
|
||||
if (label.length === 4) return `${label}-12-31`;
|
||||
return monthBoundaryTo(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
|
||||
* to whether bars are years or months and how many are visible:
|
||||
* - Year bars: pick years divisible by a step that scales with range length
|
||||
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
|
||||
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
|
||||
* one year zoomed in to months), fall back to evenly spaced ticks so we
|
||||
* show ~6 labels even when no January boundary exists.
|
||||
*/
|
||||
export function tickIndicesFor(filled: MonthBucket[]): number[] {
|
||||
if (filled.length === 0) return [];
|
||||
const isYearMode = filled[0].month.length === 4;
|
||||
const indices: number[] = [];
|
||||
|
||||
if (isYearMode) {
|
||||
const years = filled.length;
|
||||
const step =
|
||||
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const year = parseInt(filled[i].month, 10);
|
||||
if (year % step === 0) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
if (filled.length <= 18) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Long month range — pick January boundaries (year breaks).
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
if (filled[i].month.endsWith('-01')) indices.push(i);
|
||||
}
|
||||
// Fallback if there's no January in the visible range (rare): even spacing.
|
||||
if (indices.length === 0) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
|
||||
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
|
||||
* "Jan", "Feb", … without repetition.
|
||||
*/
|
||||
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
|
||||
if (label.length === 4) return label;
|
||||
const [yearStr, monthStr] = label.split('-');
|
||||
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
|
||||
const opts: Intl.DateTimeFormatOptions = omitYear
|
||||
? { month: 'short' }
|
||||
: { month: 'short', year: 'numeric' };
|
||||
return new Intl.DateTimeFormat(locale, opts).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of /documents URL params that should narrow the density chart.
|
||||
* Date bounds (`from`/`to`) are intentionally excluded — see
|
||||
* {@link fetchDensity} for why.
|
||||
*/
|
||||
export type DensityFilters = {
|
||||
q?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tags?: string[];
|
||||
tagQ?: string;
|
||||
status?: string;
|
||||
tagOp?: 'AND' | 'OR';
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the density endpoint URL, including the active non-date filters
|
||||
* so the chart matches the document list it sits above.
|
||||
*/
|
||||
export function buildDensityUrl(filters: DensityFilters = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.q) params.set('q', filters.q);
|
||||
if (filters.senderId) params.set('senderId', filters.senderId);
|
||||
if (filters.receiverId) params.set('receiverId', filters.receiverId);
|
||||
for (const tag of filters.tags ?? []) params.append('tag', tag);
|
||||
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||
const qs = params.toString();
|
||||
return qs ? `/api/documents/density?${qs}` : '/api/documents/density';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the density data for the timeline widget. Tablet and below (lg breakpoint,
|
||||
* <1024px) 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,
|
||||
filters: DensityFilters = {}
|
||||
): Promise<DensityState> {
|
||||
if (!isDesktop || view === 'calendar') return SKIP;
|
||||
|
||||
try {
|
||||
const response = await fetch(buildDensityUrl(filters));
|
||||
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 (error) {
|
||||
console.warn('[timeline] density fetch failed', error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
|
||||
const { createTimelineDrag } = await import('./useTimelineDrag.svelte');
|
||||
|
||||
type EmitCall = { start: number; end: number; includeZoom: boolean };
|
||||
|
||||
function makeOpts(overrides: Partial<Parameters<typeof createTimelineDrag>[0]> = {}) {
|
||||
const labels = ['1915-08', '1915-09', '1915-10', '1915']; // last entry is a year label
|
||||
const calls: EmitCall[] = [];
|
||||
const opts = {
|
||||
indexFromClientX: vi.fn(() => 0),
|
||||
labelAt: (i: number) => labels[i],
|
||||
isYearLabel: (l: string) => l.length === 4,
|
||||
emit: (start: number, end: number, includeZoom: boolean) => {
|
||||
calls.push({ start, end, includeZoom });
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
return { opts, calls };
|
||||
}
|
||||
|
||||
function pointerDownEvent(button = 0): PointerEvent {
|
||||
return { button } as unknown as PointerEvent;
|
||||
}
|
||||
|
||||
describe('createTimelineDrag', () => {
|
||||
beforeEach(() => {
|
||||
// Reset listeners attached to the JSDOM document between tests
|
||||
document.removeEventListener('pointermove', () => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts with no drag in progress and null low/high indices', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
expect(drag.lowIndex).toBeNull();
|
||||
expect(drag.highIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('pointerDown on a primary button enters drag state at that index', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 1);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(true);
|
||||
expect(drag.lowIndex).toBe(1);
|
||||
expect(drag.highIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('pointerDown on a non-primary button is ignored', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(2), 1);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('pointerEnter during drag widens the range high boundary', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
drag.pointerEnter(2);
|
||||
flushSync();
|
||||
expect(drag.lowIndex).toBe(0);
|
||||
expect(drag.highIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('pointerEnter outside drag is a no-op', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerEnter(2);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(false);
|
||||
expect(drag.lowIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('click on a month bar emits filter only (no zoom)', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.click(0);
|
||||
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
|
||||
});
|
||||
|
||||
it('click on a year bar emits filter + zoom atomically', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.click(3); // labels[3] = '1915' (year label)
|
||||
expect(calls).toEqual([{ start: 3, end: 3, includeZoom: true }]);
|
||||
});
|
||||
|
||||
it('range drag commits emit with zoom in ascending order', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 2) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 999 }));
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([{ start: 0, end: 2, includeZoom: true }]);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('reverse-direction drag still emits ascending boundaries via emit ordering', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 2);
|
||||
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 }));
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
// emit receives raw start/end — orchestrator's emit() handles ordering
|
||||
expect(calls).toEqual([{ start: 2, end: 0, includeZoom: true }]);
|
||||
});
|
||||
|
||||
it('pointerup on the same bar (no-range) on a month label emits filter without zoom', () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
|
||||
});
|
||||
|
||||
it('pointercancel resets state without emitting', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 1);
|
||||
document.dispatchEvent(new PointerEvent('pointercancel'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([]);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('click after pointerup is suppressed (no double emit)', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
drag.click(0); // synthesized click after pointerup
|
||||
expect(calls.length).toBe(1); // only the pointerup-driven emit, not the click
|
||||
});
|
||||
|
||||
it('cleanup removes document pointer listeners', () => {
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
drag.cleanup();
|
||||
const removed = removeSpy.mock.calls.map((c) => c[0]);
|
||||
expect(removed).toEqual(expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']));
|
||||
});
|
||||
});
|
||||
118
frontend/src/lib/document/useTimelineDrag.svelte.ts
Normal file
118
frontend/src/lib/document/useTimelineDrag.svelte.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
type DragOptions = {
|
||||
/**
|
||||
* Maps a viewport X coordinate to a bar index, or null if outside the row.
|
||||
* The orchestrator owns the row element bound from `TimelineBars`.
|
||||
*/
|
||||
indexFromClientX(clientX: number): number | null;
|
||||
/** Returns the bucket label at index, or undefined if out of range. */
|
||||
labelAt(index: number): string | undefined;
|
||||
/**
|
||||
* True when a label represents an aggregated year bar — controls click-to-zoom
|
||||
* semantics (clicking a year zooms into its 12 months; clicking a month doesn't).
|
||||
*/
|
||||
isYearLabel(label: string): boolean;
|
||||
/**
|
||||
* Emits a selection. `includeZoom` is true for a range drag or a click on a
|
||||
* year bar; false for a click on a month bar.
|
||||
*/
|
||||
emit(startIndex: number, endIndex: number, includeZoom: boolean): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drag state machine for the timeline density widget. Exposes:
|
||||
* - `isDragging`, `lowIndex`, `highIndex` reactive read-onlies for the UI
|
||||
* - `pointerDown` / `pointerEnter` / `click` handlers for bar buttons
|
||||
* - `cleanup()` to drop document-level listeners on component unmount
|
||||
*
|
||||
* Document-level listeners (pointermove, pointerup, pointercancel) keep drag
|
||||
* tracking alive while the cursor leaves the original bar or the timeline row.
|
||||
* `cleanup()` must be called from a Svelte `$effect` teardown so a route change
|
||||
* mid-drag does not leak listeners.
|
||||
*/
|
||||
export function createTimelineDrag(opts: DragOptions) {
|
||||
let startIndex = $state<number | null>(null);
|
||||
let endIndex = $state<number | null>(null);
|
||||
// Set after a pointerup-driven emit so the synthesized click that follows is
|
||||
// suppressed (we'd otherwise emit twice). Keyboard Enter/Space fires click
|
||||
// without preceding pointerdown, so click stays the keyboard surface.
|
||||
let suppressClick = $state(false);
|
||||
|
||||
function handleDocumentMove(e: PointerEvent) {
|
||||
const idx = opts.indexFromClientX(e.clientX);
|
||||
if (idx !== null) endIndex = idx;
|
||||
}
|
||||
|
||||
function handleDocumentUp() {
|
||||
cleanup();
|
||||
finalizeDrag();
|
||||
}
|
||||
|
||||
function handleDocumentCancel() {
|
||||
cleanup();
|
||||
startIndex = null;
|
||||
endIndex = null;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', handleDocumentMove);
|
||||
document.removeEventListener('pointerup', handleDocumentUp);
|
||||
document.removeEventListener('pointercancel', handleDocumentCancel);
|
||||
}
|
||||
|
||||
function finalizeDrag() {
|
||||
if (startIndex === null || endIndex === null) return;
|
||||
const start = startIndex;
|
||||
const end = endIndex;
|
||||
startIndex = null;
|
||||
endIndex = null;
|
||||
const isRangeDrag = start !== end;
|
||||
const startLabel = opts.labelAt(start);
|
||||
// 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 && opts.isYearLabel(startLabel));
|
||||
opts.emit(start, end, includeZoom);
|
||||
suppressClick = true;
|
||||
queueMicrotask(() => {
|
||||
suppressClick = false;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get isDragging() {
|
||||
return startIndex !== null;
|
||||
},
|
||||
get lowIndex() {
|
||||
if (startIndex === null) return null;
|
||||
if (endIndex === null) return startIndex;
|
||||
return Math.min(startIndex, endIndex);
|
||||
},
|
||||
get highIndex() {
|
||||
if (startIndex === null) return null;
|
||||
if (endIndex === null) return startIndex;
|
||||
return Math.max(startIndex, endIndex);
|
||||
},
|
||||
pointerDown(event: PointerEvent, index: number) {
|
||||
if (event.button !== 0) return;
|
||||
startIndex = index;
|
||||
endIndex = index;
|
||||
document.addEventListener('pointermove', handleDocumentMove);
|
||||
document.addEventListener('pointerup', handleDocumentUp);
|
||||
document.addEventListener('pointercancel', handleDocumentCancel);
|
||||
},
|
||||
pointerEnter(index: number) {
|
||||
if (startIndex === null) return;
|
||||
endIndex = index;
|
||||
},
|
||||
click(index: number) {
|
||||
if (suppressClick) {
|
||||
suppressClick = false;
|
||||
return;
|
||||
}
|
||||
const label = opts.labelAt(index);
|
||||
const includeZoom = !!label && opts.isYearLabel(label);
|
||||
opts.emit(index, index, includeZoom);
|
||||
},
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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,36 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
density: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tag?: string[];
|
||||
tagQ?: string;
|
||||
/** @description Filter by document status */
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
/** @description Tag operator: AND (default) or OR */
|
||||
tagOp?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentDensityResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||
import DocumentList from '../DocumentList.svelte';
|
||||
import Pagination from '$lib/shared/primitives/Pagination.svelte';
|
||||
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
|
||||
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
|
||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
@@ -50,6 +51,8 @@ type FilterSnapshot = {
|
||||
dir: string;
|
||||
tagQ: string;
|
||||
tagOp: 'AND' | 'OR';
|
||||
zoomFrom?: string | null;
|
||||
zoomTo?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,15 +74,30 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
|
||||
* not carried over — any filter change implicitly resets back to page 0.
|
||||
* Rebuilds the URL from the CURRENT local filter state, preserving the zoom
|
||||
* range carried in `data.zoom{From,To}`. `page` is intentionally not carried
|
||||
* over — any filter change implicitly resets back to page 0.
|
||||
*/
|
||||
function triggerSearch() {
|
||||
function triggerSearchKeepZoom() {
|
||||
navigateWithZoom(data.zoomFrom ?? null, data.zoomTo ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the URL from the CURRENT local filter state and replaces the zoom
|
||||
* range with the provided values (or clears it if both are null).
|
||||
*/
|
||||
function triggerSearchWithZoom(zoomFrom: string | null, zoomTo: string | null) {
|
||||
navigateWithZoom(zoomFrom, zoomTo);
|
||||
}
|
||||
|
||||
function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) {
|
||||
const params = buildSearchParams({
|
||||
q,
|
||||
from,
|
||||
@@ -90,7 +108,9 @@ function triggerSearch() {
|
||||
sort,
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp: tagOperator
|
||||
tagOp: tagOperator,
|
||||
zoomFrom,
|
||||
zoomTo
|
||||
});
|
||||
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
@@ -123,12 +143,12 @@ function buildPageHref(targetPage: number): string {
|
||||
|
||||
function handleTextSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||
searchTimer = setTimeout(() => triggerSearchKeepZoom(), 500);
|
||||
}
|
||||
|
||||
function handleImmediateSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
triggerSearch();
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
|
||||
// Trigger search reactively when the tag list changes.
|
||||
@@ -137,7 +157,7 @@ $effect(() => {
|
||||
const cur = tagNames.map((t) => t.name).join(',');
|
||||
if (cur !== prevTagStr) {
|
||||
prevTagStr = cur;
|
||||
triggerSearch();
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -234,6 +254,32 @@ $effect(() => {
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
<div class="mt-3 mb-4 hidden lg:block">
|
||||
<TimelineDensityFilter
|
||||
density={data.density}
|
||||
minDate={data.minDate}
|
||||
maxDate={data.maxDate}
|
||||
zoomFrom={data.zoomFrom}
|
||||
zoomTo={data.zoomTo}
|
||||
from={from}
|
||||
to={to}
|
||||
onchange={(event) => {
|
||||
from = event.from;
|
||||
to = event.to;
|
||||
// Drag commits filter + zoom atomically (Graylog-style range selector).
|
||||
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
|
||||
if ('zoomFrom' in event) {
|
||||
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null);
|
||||
} else {
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
}}
|
||||
onzoomchange={(event) => {
|
||||
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<p class="font-sans text-base text-ink-2">
|
||||
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||
|
||||
28
frontend/src/routes/documents/+page.ts
Normal file
28
frontend/src/routes/documents/+page.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { fetchDensity, type DensityFilters } from '$lib/document/timeline';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url, fetch, data }) => {
|
||||
const view = url.searchParams.get('view');
|
||||
// Tailwind `lg` breakpoint — bars below this width fall under the 44×44 touch
|
||||
// target floor (Leonie's tablet finding) and the chart skips the fetch.
|
||||
const isDesktop = browser && window.matchMedia('(min-width: 1024px)').matches;
|
||||
|
||||
// Forward active filters (excluding from/to) so the chart matches the list.
|
||||
const tagOp = url.searchParams.get('tagOp');
|
||||
const filters: DensityFilters = {
|
||||
q: url.searchParams.get('q') ?? undefined,
|
||||
senderId: url.searchParams.get('senderId') ?? undefined,
|
||||
receiverId: url.searchParams.get('receiverId') ?? undefined,
|
||||
tags: url.searchParams.getAll('tag'),
|
||||
tagQ: url.searchParams.get('tagQ') ?? undefined,
|
||||
status: url.searchParams.get('status') ?? undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : 'AND'
|
||||
};
|
||||
|
||||
const density = await fetchDensity(fetch, view, isDesktop, filters);
|
||||
const zoomFrom = url.searchParams.get('zoomFrom');
|
||||
const zoomTo = url.searchParams.get('zoomTo');
|
||||
|
||||
return { ...data, ...density, zoomFrom, zoomTo };
|
||||
};
|
||||
@@ -135,3 +135,84 @@ describe('documents page — URL building', () => {
|
||||
expect(url).not.toContain('page=');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
|
||||
|
||||
describe('documents page — timeline density widget', () => {
|
||||
it('renders the timeline widget when density data is present', async () => {
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
density: [{ month: '1915-08', count: 3 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the timeline widget when density is null (mobile / calendar view)', async () => {
|
||||
render(Page, { data: makeData({ density: null, minDate: null, maxDate: null }) });
|
||||
expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('clicking a timeline bar navigates with from/to set to that month boundary', 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'
|
||||
})
|
||||
});
|
||||
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLButtonElement;
|
||||
bar.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(goto).toHaveBeenCalledOnce();
|
||||
const [url] = vi.mocked(goto).mock.calls[0];
|
||||
expect(url).toContain('from=1915-08-01');
|
||||
expect(url).toContain('to=1915-08-31');
|
||||
});
|
||||
|
||||
it('the standalone zoom-in button no longer exists (drag replaces it)', async () => {
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
density: [{ month: '1915-08', count: 3 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31',
|
||||
from: '1915-08-01',
|
||||
to: '1915-08-31'
|
||||
})
|
||||
});
|
||||
|
||||
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
|
||||
});
|
||||
|
||||
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=');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,14 @@
|
||||
--c-badge-unknown-bg: #fdf4e3;
|
||||
--c-badge-unknown-text: #7a5a0a;
|
||||
--c-badge-unknown-border: #f0ddb3;
|
||||
|
||||
/* Timeline density bars (issue #385) — composited rgba(161,220,216,0.35)
|
||||
over --c-surface (#ffffff) ≈ #DEF3F1 → ~1.13:1 vs surface. Decorative
|
||||
idle fill in the same WCAG carve-out as --c-accent above; the meaningful
|
||||
selected/preview state uses --palette-mint (1.52:1 vs white). Resample
|
||||
with axe (tracked in #480) before tweaking the palette. */
|
||||
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
|
||||
--timeline-bar-outside: var(--c-line);
|
||||
}
|
||||
|
||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||
@@ -223,6 +231,11 @@
|
||||
--c-tag-moss: #70b060;
|
||||
--c-tag-sand: #c0a060;
|
||||
--c-tag-coral: #f07060;
|
||||
|
||||
/* Timeline density bars (issue #385) — 3.33:1 idle vs --c-surface (#011526),
|
||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||
--timeline-bar-idle: #3a6e8c;
|
||||
--timeline-bar-outside: #1a2735;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +303,11 @@
|
||||
--c-tag-moss: #70b060;
|
||||
--c-tag-sand: #c0a060;
|
||||
--c-tag-coral: #f07060;
|
||||
|
||||
/* Timeline density bars (issue #385) — 3.33:1 idle vs --c-surface (#011526),
|
||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||
--timeline-bar-idle: #3a6e8c;
|
||||
--timeline-bar-outside: #1a2735;
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
Reference in New Issue
Block a user