Relocate the 10 pure helpers (monthBoundaryFrom/To, buildMonthSequence, fillDensityGaps, clipBucketsToRange, aggregateToYears, selectionBoundaryFrom/To, tickIndicesFor, formatTickLabel) and their unit tests out of document/timeline.ts into a shared module so lib/timeline/ can consume them without importing lib/document/. The /api/documents/density glue (buildDensityUrl, fetchDensity, DensityState, DensityFilters) stays in document/timeline.ts. Re-point the three density components and the density-filter spec at the shared module. Refs #779 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
78 lines
2.5 KiB
TypeScript
78 lines
2.5 KiB
TypeScript
import type { components } from '$lib/generated/api';
|
|
|
|
type MonthBucket = components['schemas']['MonthBucket'];
|
|
type DocumentDensityResult = components['schemas']['DocumentDensityResult'];
|
|
|
|
export type DensityState = {
|
|
density: MonthBucket[] | null;
|
|
minDate: string | null;
|
|
maxDate: string | null;
|
|
};
|
|
|
|
const SKIP: DensityState = { density: null, minDate: null, maxDate: null };
|
|
const EMPTY: DensityState = { density: [], minDate: null, maxDate: null };
|
|
|
|
/**
|
|
* 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. Tablet and below (lg breakpoint,
|
|
* <1024px) and calendar view both skip the request entirely — the widget isn't
|
|
* rendered there. A non-ok response or network failure degrades to an empty
|
|
* bucket list instead of throwing, so the document list page keeps rendering.
|
|
*/
|
|
export async function fetchDensity(
|
|
fetch: typeof globalThis.fetch,
|
|
view: string | null,
|
|
isDesktop: boolean,
|
|
filters: DensityFilters = {}
|
|
): Promise<DensityState> {
|
|
if (!isDesktop || view === 'calendar') return SKIP;
|
|
|
|
try {
|
|
const response = await fetch(buildDensityUrl(filters));
|
|
if (!response.ok) {
|
|
console.warn(`[timeline] density fetch responded with ${response.status}`);
|
|
return EMPTY;
|
|
}
|
|
const body = (await response.json()) as DocumentDensityResult;
|
|
return {
|
|
density: body.buckets,
|
|
minDate: body.minDate ?? null,
|
|
maxDate: body.maxDate ?? null
|
|
};
|
|
} catch (error) {
|
|
console.warn('[timeline] density fetch failed', error);
|
|
return EMPTY;
|
|
}
|
|
}
|