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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user