Files
familienarchiv/frontend/src/lib/document/timeline.ts
Marcel 1dc3b91458 refactor(timeline): move pure month-bucket math to $lib/shared/utils/monthBuckets
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>
2026-06-13 19:18:50 +02:00

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;
}
}