232 lines
7.9 KiB
TypeScript
232 lines
7.9 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 };
|
|
|
|
export function monthBoundaryFrom(yearMonth: string): string {
|
|
return `${yearMonth}-01`;
|
|
}
|
|
|
|
export function monthBoundaryTo(yearMonth: string): string {
|
|
const [year, month] = yearMonth.split('-').map(Number);
|
|
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
|
|
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
|
|
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
|
|
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
|
|
if (!minDate || !maxDate) return [];
|
|
|
|
const [minY, minM] = minDate.split('-').map(Number);
|
|
const [maxY, maxM] = maxDate.split('-').map(Number);
|
|
|
|
const sequence: string[] = [];
|
|
let year = minY;
|
|
let month = minM;
|
|
|
|
while (year < maxY || (year === maxY && month <= maxM)) {
|
|
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
|
|
month += 1;
|
|
if (month > 12) {
|
|
month = 1;
|
|
year += 1;
|
|
}
|
|
}
|
|
|
|
return sequence;
|
|
}
|
|
|
|
export function fillDensityGaps(
|
|
buckets: MonthBucket[],
|
|
minDate: string | null,
|
|
maxDate: string | null
|
|
): MonthBucket[] {
|
|
const sequence = buildMonthSequence(minDate, maxDate);
|
|
if (sequence.length === 0) return [];
|
|
|
|
const counts = new Map(buckets.map((b) => [b.month, b.count]));
|
|
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
|
}
|
|
|
|
/**
|
|
* Returns only the month buckets whose YYYY-MM falls inside the provided
|
|
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
|
|
* input array is returned unchanged. Used by the timeline's zoom-in tool to
|
|
* narrow the visible bars without refetching data.
|
|
*
|
|
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
|
|
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
|
|
*/
|
|
export function clipBucketsToRange(
|
|
buckets: MonthBucket[],
|
|
fromInclusive: string | null,
|
|
toInclusive: string | null
|
|
): MonthBucket[] {
|
|
if (!fromInclusive || !toInclusive) return buckets;
|
|
const fromMonth = fromInclusive.slice(0, 7);
|
|
const toMonth = toInclusive.slice(0, 7);
|
|
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
|
|
}
|
|
|
|
/**
|
|
* Aggregates month-granular buckets into one entry per year. Month strings are
|
|
* truncated to "YYYY" and counts are summed. Used when the date span is too
|
|
* long for month-granular bars to render at a clickable size.
|
|
*/
|
|
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
|
|
const totals = new Map<string, number>();
|
|
for (const b of buckets) {
|
|
const year = b.month.slice(0, 4);
|
|
totals.set(year, (totals.get(year) ?? 0) + b.count);
|
|
}
|
|
return Array.from(totals.entries())
|
|
.map(([year, count]) => ({ month: year, count }))
|
|
.sort((a, b) => a.month.localeCompare(b.month));
|
|
}
|
|
|
|
/**
|
|
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
|
|
* (year) and return the matching LocalDate string.
|
|
*/
|
|
export function selectionBoundaryFrom(label: string): string {
|
|
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
|
|
}
|
|
|
|
export function selectionBoundaryTo(label: string): string {
|
|
if (label.length === 4) return `${label}-12-31`;
|
|
return monthBoundaryTo(label);
|
|
}
|
|
|
|
/**
|
|
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
|
|
* to whether bars are years or months and how many are visible:
|
|
* - Year bars: pick years divisible by a step that scales with range length
|
|
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
|
|
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
|
|
* one year zoomed in to months), fall back to evenly spaced ticks so we
|
|
* show ~6 labels even when no January boundary exists.
|
|
*/
|
|
export function tickIndicesFor(filled: MonthBucket[]): number[] {
|
|
if (filled.length === 0) return [];
|
|
const isYearMode = filled[0].month.length === 4;
|
|
const indices: number[] = [];
|
|
|
|
if (isYearMode) {
|
|
const years = filled.length;
|
|
const step =
|
|
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
|
|
for (let i = 0; i < filled.length; i++) {
|
|
const year = parseInt(filled[i].month, 10);
|
|
if (year % step === 0) indices.push(i);
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
if (filled.length <= 18) {
|
|
const step = Math.max(1, Math.round(filled.length / 6));
|
|
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
|
return indices;
|
|
}
|
|
|
|
// Long month range — pick January boundaries (year breaks).
|
|
for (let i = 0; i < filled.length; i++) {
|
|
if (filled[i].month.endsWith('-01')) indices.push(i);
|
|
}
|
|
// Fallback if there's no January in the visible range (rare): even spacing.
|
|
if (indices.length === 0) {
|
|
const step = Math.max(1, Math.round(filled.length / 6));
|
|
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
/**
|
|
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
|
|
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
|
|
* "Jan", "Feb", … without repetition.
|
|
*/
|
|
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
|
|
if (label.length === 4) return label;
|
|
const [yearStr, monthStr] = label.split('-');
|
|
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
|
|
const opts: Intl.DateTimeFormatOptions = omitYear
|
|
? { month: 'short' }
|
|
: { month: 'short', year: 'numeric' };
|
|
return new Intl.DateTimeFormat(locale, opts).format(date);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|