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(); 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 { 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; } }