feat(documents): add timeline helpers (boundary + gap-fill) (#385)

Pure utilities backing the TimelineDensityFilter component:
- monthBoundaryFrom/To convert YYYY-MM into LocalDate strings the existing
  /api/documents/search accepts (first/last day of the month).
- buildMonthSequence enumerates months between minDate and maxDate, crossing
  year boundaries.
- fillDensityGaps merges sparse backend buckets with the full month sequence,
  producing zero-count entries for months that the API omitted.

14 unit tests cover leap years, year boundaries, null inputs, and out-of-order
buckets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 22:04:21 +02:00
parent 142459b916
commit 5fdcc95c3d
2 changed files with 155 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import {
monthBoundaryFrom,
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps
} 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']);
});
});

View File

@@ -0,0 +1,47 @@
import type { components } from '$lib/generated/api';
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);
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 }));
}