feat(documents): make density endpoint filter-reactive (#385)

Density bars now recompute when other filters change so the chart always
matches the list it sits above. Selectable filters: q, senderId, receiverId,
tag (multi), tagQ, status, tagOp. 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.

Architectural shift: drop the native SQL GROUP BY in favour of in-memory
grouping over the existing Specification-driven findAll. This composes for
free with all the search predicates (FTS-rank-then-filter, sender/receiver,
tag-with-descendants, tagQ partial match, status, tagOp) and keeps the
density implementation a thin layer on top of searchDocuments. At the
current archive size (~5k docs) this stays well under the p95 200ms target;
Cache-Control: max-age=300 absorbs repeated browse loads.

- Removes findDensityByMonth, findMinMaxDocumentDate, DocumentDateRangeProjection.
- Replaces DocumentService.getDensity(LocalDate, LocalDate) with the
  filter-aware overload.
- Endpoint accepts the same query params as /api/documents/search minus
  paging+sort+from+to.
- DocumentDensityIntegrationTest rewritten as @SpringBootTest covering
  no-filter / sender / tag / status / sender+tag combos via real PostgreSQL.
- DocumentServiceTest unit tests updated to the new signature.
- DocumentControllerTest tests forwarding of senderId+tag+tagOp and q+status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 23:06:47 +02:00
parent 59a2faa145
commit e92e9e452e
7 changed files with 232 additions and 136 deletions

View File

@@ -392,9 +392,16 @@ public class DocumentController {
@GetMapping("/density")
public ResponseEntity<DocumentDensityResult> density(
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to) {
DocumentDensityResult result = documentService.getDensity(from, to);
@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(
q, senderId, receiverId, tags, tagQ, status, operator);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
.body(result);

View File

@@ -1,13 +0,0 @@
package org.raddatz.familienarchiv.document;
import java.time.LocalDate;
/**
* Spring Data projection for the document_date min/max query backing the timeline
* density widget (issue #385). Column aliases in the native SQL must match these
* getter names exactly. Both values are {@code null} when the documents table is empty.
*/
public interface DocumentDateRangeProjection {
LocalDate getMinDate();
LocalDate getMaxDate();
}

View File

@@ -240,28 +240,4 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
""")
TranscriptionWeeklyStatsProjection findWeeklyStats();
/**
* Document count grouped by calendar month for the timeline density widget (issue #385).
* Each row is {@code Object[]{ String yearMonth, long count }}.
* Months without documents are not present — the frontend fills gaps from minDate/maxDate.
*/
@Query(nativeQuery = true, value = """
SELECT TO_CHAR(DATE_TRUNC('month', meta_date), 'YYYY-MM') AS month,
COUNT(*) AS doc_count
FROM documents
WHERE meta_date IS NOT NULL
AND (CAST(:from AS date) IS NULL OR meta_date >= :from)
AND (CAST(:to AS date) IS NULL OR meta_date <= :to)
GROUP BY 1
ORDER BY 1
""")
List<Object[]> findDensityByMonth(@Param("from") LocalDate from, @Param("to") LocalDate to);
/**
* Earliest and latest {@code meta_date} across all documents. Both getters return
* {@code null} when the table holds no documents.
*/
@Query(nativeQuery = true, value = "SELECT MIN(meta_date) AS minDate, MAX(meta_date) AS maxDate FROM documents")
DocumentDateRangeProjection findMinMaxDocumentDate();
}

View File

@@ -127,15 +127,54 @@ public class DocumentService {
/**
* Per-month document counts for the timeline density widget (issue #385).
* Returns only months that have at least one document — the frontend fills
* gaps using the {@code minDate}/{@code maxDate} bounds.
*
* <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.
*/
public DocumentDensityResult getDensity(LocalDate from, LocalDate to) {
List<MonthBucket> buckets = documentRepository.findDensityByMonth(from, to).stream()
.map(row -> new MonthBucket((String) row[0], ((Number) row[1]).intValue()))
public DocumentDensityResult getDensity(
String text, UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) {
return new DocumentDensityResult(List.of(), null, null);
}
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, null, null,
sender, receiver, tags, tagQ, status, tagOperator);
List<LocalDate> dates = documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate)
.filter(Objects::nonNull)
.toList();
DocumentDateRangeProjection range = documentRepository.findMinMaxDocumentDate();
return new DocumentDensityResult(buckets, range.getMinDate(), range.getMaxDate());
if (dates.isEmpty()) {
return new DocumentDensityResult(List.of(), null, null);
}
Map<String, Integer> counts = new java.util.TreeMap<>();
for (LocalDate d : dates) {
String month = String.format("%04d-%02d", d.getYear(), d.getMonthValue());
counts.merge(month, 1, Integer::sum);
}
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);
}
/**