diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts index 63d9b58b..097705f5 100644 --- a/frontend/src/lib/document/timeline.spec.ts +++ b/frontend/src/lib/document/timeline.spec.ts @@ -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 }); diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts index c82786ad..ac6836bb 100644 --- a/frontend/src/lib/document/timeline.ts +++ b/frontend/src/lib/document/timeline.ts @@ -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 { 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 { diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 81ae2d19..592a3a9c 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2253,14 +2253,14 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ numberOfElements?: number; empty?: boolean; @@ -4959,8 +4959,15 @@ export interface operations { density: { parameters: { query?: { - from?: string; - to?: string; + 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; diff --git a/frontend/src/routes/documents/+page.ts b/frontend/src/routes/documents/+page.ts index 0127696a..d79459bb 100644 --- a/frontend/src/routes/documents/+page.ts +++ b/frontend/src/routes/documents/+page.ts @@ -1,10 +1,23 @@ import { browser } from '$app/environment'; -import { fetchDensity } from '$lib/document/timeline'; +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'); const isDesktop = browser && window.matchMedia('(min-width: 640px)').matches; - const density = await fetchDensity(fetch, view, isDesktop); + + // 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); return { ...data, ...density }; };