From f34d42a09fca8ab3c0dbc703a367251053b20281 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:20:37 +0200 Subject: [PATCH] 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 --- .../src/lib/timeline/timelineDensity.spec.ts | 110 ++++++++++++++++++ frontend/src/lib/timeline/timelineDensity.ts | 32 +++++ 2 files changed, 142 insertions(+) create mode 100644 frontend/src/lib/timeline/timelineDensity.spec.ts create mode 100644 frontend/src/lib/timeline/timelineDensity.ts diff --git a/frontend/src/lib/timeline/timelineDensity.spec.ts b/frontend/src/lib/timeline/timelineDensity.spec.ts new file mode 100644 index 00000000..d7157241 --- /dev/null +++ b/frontend/src/lib/timeline/timelineDensity.spec.ts @@ -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 = { + '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); + }); +}); diff --git a/frontend/src/lib/timeline/timelineDensity.ts b/frontend/src/lib/timeline/timelineDensity.ts new file mode 100644 index 00000000..5dab1641 --- /dev/null +++ b/frontend/src/lib/timeline/timelineDensity.ts @@ -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(); + 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`); +}