From 5fdcc95c3d9e8221332b2bdd1c45373ea3e6ab9a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:04:21 +0200 Subject: [PATCH] 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 --- frontend/src/lib/document/timeline.spec.ts | 108 +++++++++++++++++++++ frontend/src/lib/document/timeline.ts | 47 +++++++++ 2 files changed, 155 insertions(+) create mode 100644 frontend/src/lib/document/timeline.spec.ts create mode 100644 frontend/src/lib/document/timeline.ts diff --git a/frontend/src/lib/document/timeline.spec.ts b/frontend/src/lib/document/timeline.spec.ts new file mode 100644 index 00000000..871eec52 --- /dev/null +++ b/frontend/src/lib/document/timeline.spec.ts @@ -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']); + }); +}); diff --git a/frontend/src/lib/document/timeline.ts b/frontend/src/lib/document/timeline.ts new file mode 100644 index 00000000..2fa09899 --- /dev/null +++ b/frontend/src/lib/document/timeline.ts @@ -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 })); +}