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:
@@ -5,6 +5,7 @@ import {
|
|||||||
buildMonthSequence,
|
buildMonthSequence,
|
||||||
fillDensityGaps,
|
fillDensityGaps,
|
||||||
fetchDensity,
|
fetchDensity,
|
||||||
|
buildDensityUrl,
|
||||||
aggregateToYears,
|
aggregateToYears,
|
||||||
selectionBoundaryFrom,
|
selectionBoundaryFrom,
|
||||||
selectionBoundaryTo
|
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', () => {
|
describe('fetchDensity', () => {
|
||||||
it('skips fetch and returns null density on mobile', async () => {
|
it('skips fetch and returns null density on mobile', async () => {
|
||||||
const fetch = vi.fn();
|
const fetch = vi.fn();
|
||||||
@@ -189,6 +223,19 @@ describe('fetchDensity', () => {
|
|||||||
expect(result.maxDate).toBe('1916-12-31');
|
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 () => {
|
it('returns empty density and null bounds when the API responds non-ok', async () => {
|
||||||
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,38 @@ export function selectionBoundaryTo(label: string): string {
|
|||||||
return monthBoundaryTo(label);
|
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)
|
* 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
|
* 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(
|
export async function fetchDensity(
|
||||||
fetch: typeof globalThis.fetch,
|
fetch: typeof globalThis.fetch,
|
||||||
view: string | null,
|
view: string | null,
|
||||||
isDesktop: boolean
|
isDesktop: boolean,
|
||||||
|
filters: DensityFilters = {}
|
||||||
): Promise<DensityState> {
|
): Promise<DensityState> {
|
||||||
if (!isDesktop || view === 'calendar') return SKIP;
|
if (!isDesktop || view === 'calendar') return SKIP;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/documents/density');
|
const response = await fetch(buildDensityUrl(filters));
|
||||||
if (!response.ok) return EMPTY;
|
if (!response.ok) return EMPTY;
|
||||||
const body = (await response.json()) as DocumentDensityResult;
|
const body = (await response.json()) as DocumentDensityResult;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2253,14 +2253,14 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
pageable?: components["schemas"]["PageableObject"];
|
pageable?: components["schemas"]["PageableObject"];
|
||||||
|
first?: boolean;
|
||||||
|
last?: boolean;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
size?: number;
|
size?: number;
|
||||||
content?: components["schemas"]["NotificationDTO"][];
|
content?: components["schemas"]["NotificationDTO"][];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
number?: number;
|
number?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
first?: boolean;
|
|
||||||
last?: boolean;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
numberOfElements?: number;
|
numberOfElements?: number;
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
@@ -4959,8 +4959,15 @@ export interface operations {
|
|||||||
density: {
|
density: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
from?: string;
|
q?: string;
|
||||||
to?: 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;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { fetchDensity } from '$lib/document/timeline';
|
import { fetchDensity, type DensityFilters } from '$lib/document/timeline';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ url, fetch, data }) => {
|
export const load: PageLoad = async ({ url, fetch, data }) => {
|
||||||
const view = url.searchParams.get('view');
|
const view = url.searchParams.get('view');
|
||||||
const isDesktop = browser && window.matchMedia('(min-width: 640px)').matches;
|
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 };
|
return { ...data, ...density };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user