feat(documents): timeline density refetches when other filters change (#385)

The +page.ts client-side load now forwards the active /documents URL
filters (q, senderId, receiverId, tag, tagQ, status, tagOp) to
/api/documents/density so the bars recompute when the user narrows the
search. Date bounds (from/to) are deliberately omitted — the chart is
the surface for picking those.

- New `DensityFilters` type and `buildDensityUrl(filters)` helper.
- `fetchDensity` accepts a filter snapshot (defaulting to {} for
  back-compat in tests).
- 6 new unit tests cover URL building, multi-tag repetition, AND/OR
  forwarding, the explicit-no-from/to invariant, and filter-aware fetch.
- Generated API types refreshed against the new backend signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 23:10:12 +02:00
parent e92e9e452e
commit 76023a99ed
4 changed files with 108 additions and 8 deletions

View File

@@ -5,6 +5,7 @@ import {
buildMonthSequence,
fillDensityGaps,
fetchDensity,
buildDensityUrl,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo
@@ -152,6 +153,39 @@ describe('selectionBoundaryFrom / To', () => {
});
});
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();
@@ -189,6 +223,19 @@ describe('fetchDensity', () => {
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 });

View File

@@ -85,6 +85,38 @@ export function selectionBoundaryTo(label: string): string {
return monthBoundaryTo(label);
}
/**
* 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. Mobile (sm: breakpoint and below)
* and calendar view both skip the request entirely — the widget isn't rendered
@@ -94,12 +126,13 @@ export function selectionBoundaryTo(label: string): string {
export async function fetchDensity(
fetch: typeof globalThis.fetch,
view: string | null,
isDesktop: boolean
isDesktop: boolean,
filters: DensityFilters = {}
): Promise<DensityState> {
if (!isDesktop || view === 'calendar') return SKIP;
try {
const response = await fetch('/api/documents/density');
const response = await fetch(buildDensityUrl(filters));
if (!response.ok) return EMPTY;
const body = (await response.json()) as DocumentDensityResult;
return {