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