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

View File

@@ -0,0 +1,32 @@
import type { components } from '$lib/generated/api';
import { fillDensityGaps, type MonthBucket } from '$lib/shared/utils/monthBuckets';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A year band with more letters than this renders as a compact density strip
* (count + 12-month sparkline) instead of one card per letter (REQ-012).
*/
export const DENSE_THRESHOLD = 12;
export function isDense(letterCount: number): boolean {
return letterCount > DENSE_THRESHOLD;
}
/**
* Buckets a band's letters into exactly 12 month buckets (`{year}-01`..`{year}-12`)
* for the density sparkline. Each letter counts on its `eventDate` month; coarser
* precisions (SEASON/YEAR/APPROX) count on whatever anchor month the backend put
* in `eventDate`. Entries without an `eventDate` (e.g. UNKNOWN) are ignored — they
* live in the "Ohne Datum" bucket, not a dated band. (REQ-027)
*/
export function monthHistogram(letters: TimelineEntryDTO[], year: number): MonthBucket[] {
const counts = new Map<string, number>();
for (const l of letters) {
if (!l.eventDate) continue;
const month = l.eventDate.slice(0, 7); // YYYY-MM
counts.set(month, (counts.get(month) ?? 0) + 1);
}
const buckets = Array.from(counts.entries()).map(([month, count]) => ({ month, count }));
return fillDensityGaps(buckets, `${year}-01-01`, `${year}-12-31`);
}