feat(timeline): add timelineDensity helper (isDense, monthHistogram)

isDense(count) thresholds dense year bands at >12 letters (REQ-012);
monthHistogram(letters, year) buckets a band's letters into exactly 12 month
buckets via the shared fillDensityGaps, counting each letter on its eventDate
anchor month and ignoring undated entries (REQ-027). Imports shared only.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 19:20:37 +02:00
parent 1dc3b91458
commit f34d42a09f
2 changed files with 142 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { isDense, monthHistogram, DENSE_THRESHOLD } from './timelineDensity';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
function letter(eventDate: string): TimelineEntryDTO {
return {
kind: 'LETTER',
precision: 'DAY',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede',
eventDate
};
}
describe('isDense', () => {
it('uses a threshold of 12', () => {
expect(DENSE_THRESHOLD).toBe(12);
});
it('is false at exactly 12 letters (still rendered as individual cards)', () => {
expect(isDense(12)).toBe(false);
});
it('is true above 12 letters (collapses to a strip)', () => {
expect(isDense(13)).toBe(true);
});
it('is false for empty and small bands', () => {
expect(isDense(0)).toBe(false);
expect(isDense(3)).toBe(false);
});
});
describe('monthHistogram', () => {
it('returns exactly 12 buckets for the band year, Jan..Dec', () => {
const buckets = monthHistogram([letter('1915-03-04')], 1915);
expect(buckets).toHaveLength(12);
expect(buckets.map((b) => b.month)).toEqual([
'1915-01',
'1915-02',
'1915-03',
'1915-04',
'1915-05',
'1915-06',
'1915-07',
'1915-08',
'1915-09',
'1915-10',
'1915-11',
'1915-12'
]);
});
it('counts each letter on its eventDate month; counts sum to the total', () => {
// 30 letters spread one-or-more per month across 1915.
const dist: Record<string, number> = {
'01': 1,
'02': 2,
'03': 3,
'04': 4,
'05': 1,
'06': 5,
'07': 2,
'08': 6,
'09': 1,
'10': 2,
'11': 2,
'12': 1
};
const letters: TimelineEntryDTO[] = [];
for (const [mm, n] of Object.entries(dist)) {
for (let i = 0; i < n; i++) letters.push(letter(`1915-${mm}-10`));
}
expect(letters).toHaveLength(30);
const buckets = monthHistogram(letters, 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(30);
for (const b of buckets) {
expect(b.count).toBe(dist[b.month.slice(5)]);
}
});
it('yields height 0 for the eleven empty months when letters cluster in one', () => {
const buckets = monthHistogram([letter('1915-03-01'), letter('1915-03-28')], 1915);
const march = buckets.find((b) => b.month === '1915-03');
expect(march?.count).toBe(2);
expect(buckets.filter((b) => b.month !== '1915-03').every((b) => b.count === 0)).toBe(true);
});
it('counts coarser-than-month precisions on their eventDate anchor month', () => {
const seasonLetter: TimelineEntryDTO = { ...letter('1915-07-01'), precision: 'SEASON' };
const buckets = monthHistogram([seasonLetter], 1915);
expect(buckets.find((b) => b.month === '1915-07')?.count).toBe(1);
});
it('ignores entries without an eventDate', () => {
const undated: TimelineEntryDTO = {
kind: 'LETTER',
precision: 'UNKNOWN',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede'
};
const buckets = monthHistogram([undated, letter('1915-05-01')], 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(1);
});
});