Compare commits

...

2 Commits

Author SHA1 Message Date
Marcel
c6137a26a2 feat(documents): show global undated count chip on the filter toggle
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Failing after 4m3s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Surface the backend's global undatedCount on the "Nur undatierte" toggle as
a count chip — the total undated documents matching the current filter
across all pages, not the page slice. The loader forwards undatedCount
straight through (defaulting to 0); the chip hides at 0 and stays visible
regardless of the toggle state so it advertises the triage backlog size.

generate:api was hand-edited (undatedCount added to DocumentSearchResult) —
CI must re-run npm run generate:api to confirm parity.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:42:57 +02:00
Marcel
a3c3f14aea feat(documents): return global undated count in search response
The undated bucket count was page-local — derived from the year-grouping
of the current page's items, so it could never exceed the page size. The
owner's decision is for it to reflect ALL undated documents matching the
active filter across every page.

Add an undatedCount field to DocumentSearchResult, computed once per search
via a COUNT over the same filter spec with undatedOnly(true) forced —
independent of the "Nur undatierte" toggle so it never collapses to the
page slice or double-counts. A from/to range excludes undated rows by the
collision rule, so the count is legitimately 0 inside a date range.

Refs #668

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:42:32 +02:00
10 changed files with 247 additions and 11 deletions

View File

@@ -15,24 +15,45 @@ public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageSize,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int totalPages
int totalPages,
/**
* Total number of undated documents (meta_date IS NULL) matching the current
* filter context (q/tags/sender/receiver/status) across ALL pages — not the
* undated rows on the current page. Computed independently of the "Nur
* undatierte" toggle so it never collapses to the page slice (issue #668).
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long undatedCount
) {
/**
* Single-page convenience factory used by empty-result shortcuts and by tests that
* don't care about paging. Treats the whole list as page 0 of itself.
* don't care about paging. Treats the whole list as page 0 of itself. The undated
* count defaults to 0 — the service overlays the real global count via
* {@link #withUndatedCount(long)} before returning.
*/
public static DocumentSearchResult of(List<DocumentListItem> items) {
int size = items.size();
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1, 0L);
}
/**
* Paged factory used by the service when it has a real Pageable + full match count
* (e.g. from Spring's Page&lt;T&gt; or from an in-memory sort-then-slice).
* (e.g. from Spring's Page&lt;T&gt; or from an in-memory sort-then-slice). The undated
* count defaults to 0 — the service overlays the real global count via
* {@link #withUndatedCount(long)} before returning.
*/
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
int pageSize = pageable.getPageSize();
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages, 0L);
}
/**
* Returns a copy with the global undated count overlaid, leaving every other
* field untouched. Lets the service compute the count once and attach it to
* whichever result shape the search path produced.
*/
public DocumentSearchResult withUndatedCount(long undatedCount) {
return new DocumentSearchResult(items, totalElements, pageNumber, pageSize, totalPages, undatedCount);
}
}

View File

@@ -669,6 +669,43 @@ public class DocumentService {
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
// FTS matched nothing → no results and, by definition, no undated matches either.
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
// Global undated count for the current filter (q/tags/sender/receiver/status),
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
// it never collapses to the page slice and never double-counts (issue #668).
long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable)
.withUndatedCount(undatedCount);
}
/**
* Counts every undated document (meta_date IS NULL) matching the active filter,
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
* so the count is legitimately 0 inside a date range.
*/
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
Specification<Document> undatedSpec = buildSearchSpec(
hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true);
return documentRepository.count(undatedSpec);
}
/** The original search dispatch — produces the page slice + totals, sans undated count. */
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status,
DocumentSort sort, String dir, TagOperator tagOperator,
boolean undated, Pageable pageable) {
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
// An active undated filter must NOT take this path: it bypasses buildSearchSpec, so the
// undatedOnly predicate would be silently dropped.
@@ -676,12 +713,6 @@ public class DocumentService {
return relevanceSortedPageFromSql(text, pageable);
}
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);

View File

@@ -108,6 +108,83 @@ class DocumentSearchPagedIntegrationTest {
assertThat(result.totalPages()).isEqualTo(3);
}
@Test
void search_undatedCount_isGlobalFilteredTotal_notPageSlice() {
// Seed 70 undated docs on top of the 120 dated ones. With a 50-per-page
// window the undated rows span multiple pages, so a page-local count could
// never exceed 50 — the global count must be the full 70 (issue #668).
int undatedTotal = 70;
for (int i = 0; i < undatedTotal; i++) {
documentRepository.save(Document.builder()
.title("Undatiert-" + String.format("%03d", i))
.originalFilename("undatiert-" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.metaDatePrecision(DatePrecision.UNKNOWN)
.documentDate(null)
.build());
}
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
// Global undated count is the full undated total, independent of page size.
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
// Total matches both dated + undated (no undated-only filter applied).
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE + undatedTotal);
// The first DATE-DESC page is all dated rows (nulls last), so a page-local
// tally would report 0 undated — proving the count is not page-derived.
assertThat(result.items()).allMatch(item -> item.documentDate() != null);
}
@Test
void search_undatedCount_ignoresUndatedOnlyToggle() {
// The "Nur undatierte" toggle must not skew the count: whether undated=true or
// false, the global undated count for the same filter is identical (issue #668).
int undatedTotal = 12;
for (int i = 0; i < undatedTotal; i++) {
documentRepository.save(Document.builder()
.title("U-" + i)
.originalFilename("u-" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.metaDatePrecision(DatePrecision.UNKNOWN)
.documentDate(null)
.build());
}
DocumentSearchResult unfiltered = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
DocumentSearchResult undatedOnly = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
}
@Test
void search_undatedCount_isZero_insideDateRange() {
// A from/to range excludes undated rows by the collision rule (#668), so the
// global undated count inside a range is legitimately 0 even when undated docs exist.
for (int i = 0; i < 5; i++) {
documentRepository.save(Document.builder()
.title("U-range-" + i)
.originalFilename("u-range-" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.metaDatePrecision(DatePrecision.UNKNOWN)
.documentDate(null)
.build());
}
DocumentSearchResult result = documentService.searchDocuments(
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
assertThat(result.undatedCount()).isZero();
}
@Test
void search_differentPagesReturnDisjointSlices() {
DocumentSearchResult page0 = documentService.searchDocuments(

View File

@@ -99,4 +99,32 @@ class DocumentSearchResultTest {
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
}
@Test
void undatedCount_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("undatedCount").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
@Test
void factories_default_undatedCount_to_zero() {
assertThat(DocumentSearchResult.of(List.of()).undatedCount()).isZero();
assertThat(DocumentSearchResult.paged(List.of(), PageRequest.of(0, 50), 0L).undatedCount()).isZero();
}
@Test
void withUndatedCount_overlays_count_and_preserves_other_fields() {
DocumentSearchResult base = DocumentSearchResult.paged(
List.of(item(UUID.randomUUID())), PageRequest.of(1, 50), 120L);
DocumentSearchResult withCount = base.withUndatedCount(7L);
assertThat(withCount.undatedCount()).isEqualTo(7L);
assertThat(withCount.items()).isEqualTo(base.items());
assertThat(withCount.totalElements()).isEqualTo(120L);
assertThat(withCount.pageNumber()).isEqualTo(1);
assertThat(withCount.pageSize()).isEqualTo(50);
assertThat(withCount.totalPages()).isEqualTo(3);
}
}

View File

@@ -2471,6 +2471,8 @@ export interface components {
pageSize: number;
/** Format: int32 */
totalPages: number;
/** Format: int64 */
undatedCount: number;
};
MatchOffset: {
/** Format: int32 */

View File

@@ -16,6 +16,7 @@ let {
tagQ = $bindable(''),
tagOperator = $bindable<'AND' | 'OR'>('AND'),
undated = $bindable(false),
undatedCount = 0,
sort = $bindable('DATE'),
dir = $bindable('desc'),
showAdvanced = $bindable(false),
@@ -37,6 +38,7 @@ let {
tagQ?: string;
tagOperator?: 'AND' | 'OR';
undated?: boolean;
undatedCount?: number;
sort?: string;
dir?: string;
showAdvanced?: boolean;
@@ -275,6 +277,18 @@ $effect(() => {
{#if undated}{/if}
</span>
{m.docs_filter_undated_only()}
<!-- Global count of undated docs matching the current filter across ALL
pages (not the page slice). Stays visible regardless of the toggle
state so it advertises the triage backlog size (issue #668). -->
{#if undatedCount > 0}
<span
data-testid="undated-count"
class="inline-flex min-w-[1.5rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[0.65rem] leading-none tabular-nums {undated
? 'bg-primary-fg/20 text-primary-fg'
: 'bg-line text-ink-2'}"
>{undatedCount}</span
>
{/if}
</button>
</div>
</div>

View File

@@ -162,6 +162,20 @@ describe('SearchFilterBar undated-only toggle (#668)', () => {
await page.getByTestId('undated-only-toggle').click();
await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0);
});
it('shows the global undated count chip when undatedCount > 0', async () => {
// The count is the backend's global filtered total (#668), passed straight
// through — the chip must render it verbatim, not a page-derived number.
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 42 });
await openAdvanced();
await expect.element(page.getByTestId('undated-count')).toHaveTextContent('42');
});
it('hides the undated count chip when undatedCount is 0', async () => {
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 0 });
await openAdvanced();
await expect.element(page.getByTestId('undated-count')).not.toBeInTheDocument();
});
});
describe('SearchFilterBar tagQ live filter', () => {

View File

@@ -85,6 +85,7 @@ export async function load({ url, fetch }) {
pageNumber: 0,
pageSize: PAGE_SIZE,
totalPages: 0,
undatedCount: 0,
q,
from,
to,
@@ -116,6 +117,8 @@ export async function load({ url, fetch }) {
pageNumber: result.data?.pageNumber ?? page,
pageSize: result.data?.pageSize ?? PAGE_SIZE,
totalPages: result.data?.totalPages ?? 0,
// Global undated count for the active filter, across all pages (issue #668).
undatedCount: result.data?.undatedCount ?? 0,
q,
from,
to,

View File

@@ -268,6 +268,7 @@ $effect(() => {
bind:tagQ={tagQ}
bind:tagOperator={tagOperator}
bind:undated={undated}
undatedCount={data.undatedCount ?? 0}
initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName}
navKey={navKey}

View File

@@ -225,6 +225,51 @@ describe('documents page load — search params', () => {
expect(result.totalElements).toBe(42);
});
it('forwards the global undatedCount from the search result (#668)', async () => {
// The backend returns the global undated total for the active filter across
// ALL pages; the loader must pass it straight through, not recompute it locally.
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: {
items: [],
totalElements: 200,
pageNumber: 0,
pageSize: 50,
totalPages: 4,
undatedCount: 73
}
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({
url: makeUrl({ q: 'test' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.undatedCount).toBe(73);
});
it('defaults undatedCount to 0 when the search result omits it', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.undatedCount).toBe(0);
});
it('returns filter values in the result for pre-filling the UI', async () => {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },