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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { formatTickLabel } from '$lib/document/timeline';
|
||||
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
formatTickLabel
|
||||
} from '$lib/document/timeline';
|
||||
} from '$lib/shared/utils/monthBuckets';
|
||||
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import TimelineBars from '$lib/document/TimelineBars.svelte';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
|
||||
import { formatTickLabel } from './timeline';
|
||||
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline';
|
||||
import { formatTickLabel, tickIndicesFor } from '$lib/shared/utils/monthBuckets';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
|
||||
@@ -1,191 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
monthBoundaryFrom,
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
fetchDensity,
|
||||
buildDensityUrl,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} from './timeline';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
it('returns the first day of the given month', () => {
|
||||
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
});
|
||||
|
||||
it('handles January', () => {
|
||||
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthBoundaryTo', () => {
|
||||
it('returns the last day of a 31-day month', () => {
|
||||
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('returns the last day of a 30-day month', () => {
|
||||
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
|
||||
});
|
||||
|
||||
it('returns 28 for February in a non-leap year', () => {
|
||||
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
|
||||
});
|
||||
|
||||
it('returns 29 for February in a leap year', () => {
|
||||
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMonthSequence', () => {
|
||||
it('returns a single month when min and max are in the same month', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
|
||||
});
|
||||
|
||||
it('returns months from minDate through maxDate inclusive', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
|
||||
'1915-08',
|
||||
'1915-09',
|
||||
'1915-10',
|
||||
'1915-11'
|
||||
]);
|
||||
});
|
||||
|
||||
it('crosses year boundaries correctly', () => {
|
||||
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
|
||||
'1915-11',
|
||||
'1915-12',
|
||||
'1916-01',
|
||||
'1916-02'
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
|
||||
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
|
||||
expect(buildMonthSequence(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillDensityGaps', () => {
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(fillDensityGaps([], null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all-zero sequence when buckets array is empty', () => {
|
||||
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 0 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-08', count: 1 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
|
||||
|
||||
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipBucketsToRange', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 },
|
||||
{ month: '1915-11', count: 3 }
|
||||
];
|
||||
|
||||
it('returns the original buckets when range bounds are null', () => {
|
||||
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
|
||||
});
|
||||
|
||||
it('keeps only buckets whose month falls within the range', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the range excludes everything', () => {
|
||||
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats partial dates correctly when bounds cross month boundaries', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionBoundaryFrom / To', () => {
|
||||
it('handles month labels (YYYY-MM)', () => {
|
||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
import { fetchDensity, buildDensityUrl } from './timeline';
|
||||
|
||||
describe('buildDensityUrl', () => {
|
||||
it('returns the bare endpoint when no filters provided', () => {
|
||||
@@ -309,84 +123,3 @@ describe('fetchDensity', () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickIndicesFor', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,160 +12,6 @@ export type DensityState = {
|
||||
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
|
||||
|
||||
267
frontend/src/lib/shared/utils/monthBuckets.spec.ts
Normal file
267
frontend/src/lib/shared/utils/monthBuckets.spec.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
monthBoundaryFrom,
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} from './monthBuckets';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
it('returns the first day of the given month', () => {
|
||||
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
});
|
||||
|
||||
it('handles January', () => {
|
||||
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthBoundaryTo', () => {
|
||||
it('returns the last day of a 31-day month', () => {
|
||||
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('returns the last day of a 30-day month', () => {
|
||||
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
|
||||
});
|
||||
|
||||
it('returns 28 for February in a non-leap year', () => {
|
||||
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
|
||||
});
|
||||
|
||||
it('returns 29 for February in a leap year', () => {
|
||||
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMonthSequence', () => {
|
||||
it('returns a single month when min and max are in the same month', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
|
||||
});
|
||||
|
||||
it('returns months from minDate through maxDate inclusive', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
|
||||
'1915-08',
|
||||
'1915-09',
|
||||
'1915-10',
|
||||
'1915-11'
|
||||
]);
|
||||
});
|
||||
|
||||
it('crosses year boundaries correctly', () => {
|
||||
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
|
||||
'1915-11',
|
||||
'1915-12',
|
||||
'1916-01',
|
||||
'1916-02'
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
|
||||
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
|
||||
expect(buildMonthSequence(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillDensityGaps', () => {
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(fillDensityGaps([], null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all-zero sequence when buckets array is empty', () => {
|
||||
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 0 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-08', count: 1 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
|
||||
|
||||
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipBucketsToRange', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 },
|
||||
{ month: '1915-11', count: 3 }
|
||||
];
|
||||
|
||||
it('returns the original buckets when range bounds are null', () => {
|
||||
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
|
||||
});
|
||||
|
||||
it('keeps only buckets whose month falls within the range', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the range excludes everything', () => {
|
||||
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats partial dates correctly when bounds cross month boundaries', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionBoundaryFrom / To', () => {
|
||||
it('handles month labels (YYYY-MM)', () => {
|
||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickIndicesFor', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
163
frontend/src/lib/shared/utils/monthBuckets.ts
Normal file
163
frontend/src/lib/shared/utils/monthBuckets.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
/**
|
||||
* Pure month-bucket math shared by the document density chart (`lib/document/`)
|
||||
* and the global timeline strip (`lib/timeline/`). Reuses the generated
|
||||
* `MonthBucket` schema type so both surfaces stay coupled to the backend shape.
|
||||
* No I/O, no DOM — relocated here so `lib/timeline/` never imports `lib/document/`.
|
||||
*/
|
||||
export type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
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 (`monthBuckets.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);
|
||||
}
|
||||
Reference in New Issue
Block a user