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:
110
frontend/src/lib/timeline/timelineDensity.spec.ts
Normal file
110
frontend/src/lib/timeline/timelineDensity.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
frontend/src/lib/timeline/timelineDensity.ts
Normal file
32
frontend/src/lib/timeline/timelineDensity.ts
Normal 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user