From be44474f8afe8d19a165165587a35a1665e2c2fe Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 20:33:49 +0200 Subject: [PATCH] feat(timeline): add the event-clustering split helper buildEventLookup maps each curated event in the (already layer-filtered) timeline to its title; splitYearLetters partitions a year's letters into event clusters (keyed by a linkedEventId present in the lookup) and the loose chronological remainder. A letter linking to a filtered-out event falls back to loose (filter-then-cluster); each letter appears once. Refs #850 --- .../src/lib/timeline/eventClustering.spec.ts | 110 ++++++++++++++++++ frontend/src/lib/timeline/eventClustering.ts | 74 ++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 frontend/src/lib/timeline/eventClustering.spec.ts create mode 100644 frontend/src/lib/timeline/eventClustering.ts diff --git a/frontend/src/lib/timeline/eventClustering.spec.ts b/frontend/src/lib/timeline/eventClustering.spec.ts new file mode 100644 index 00000000..eb90f07e --- /dev/null +++ b/frontend/src/lib/timeline/eventClustering.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { buildEventLookup, splitYearLetters, CLUSTER_PREVIEW } from './eventClustering'; +import { makeEntry } from './test-factories'; +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +const EV_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const EV_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +const makeEvent = (overrides: Partial = {}): TimelineEntryDTO => + makeEntry({ kind: 'EVENT', type: 'PERSONAL', documentId: undefined, ...overrides }); + +describe('eventClustering — buildEventLookup', () => { + it('maps each curated event (kind EVENT + eventId) to its title across years + undated', () => { + const timeline: TimelineDTO = { + years: [ + { + year: 1916, + entries: [makeEvent({ eventId: EV_A, title: 'Ein gewaltiger Stadtbrand' })] + } + ], + undated: [makeEvent({ eventId: EV_B, title: 'Briefe von der Front' })] + }; + const lookup = buildEventLookup(timeline); + expect(lookup.get(EV_A)).toBe('Ein gewaltiger Stadtbrand'); + expect(lookup.get(EV_B)).toBe('Briefe von der Front'); + expect(lookup.size).toBe(2); + }); + + it('ignores derived events (no eventId) and letters', () => { + const timeline: TimelineDTO = { + years: [ + { + year: 1916, + entries: [ + makeEvent({ eventId: undefined, title: 'Geburt' }), // derived + makeEntry({ kind: 'LETTER', documentId: 'doc-1' }) + ] + } + ], + undated: [] + }; + expect(buildEventLookup(timeline).size).toBe(0); + }); +}); + +describe('eventClustering — splitYearLetters', () => { + it('exposes a CLUSTER_PREVIEW of 5', () => { + expect(CLUSTER_PREVIEW).toBe(5); + }); + + it('clusters letters by linkedEventId with matching counts', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [ + makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }), + makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: EV_A }) + ]; + const { clusters, loose } = splitYearLetters(letters, lookup); + expect(clusters).toHaveLength(1); + expect(clusters[0].eventId).toBe(EV_A); + expect(clusters[0].title).toBe('Stadtbrand'); + expect(clusters[0].letters).toHaveLength(2); + expect(loose).toHaveLength(0); + }); + + it('keeps a letter with no linkedEventId loose', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: undefined })]; + const { clusters, loose } = splitYearLetters(letters, lookup); + expect(clusters).toHaveLength(0); + expect(loose).toHaveLength(1); + }); + + it('keeps a letter whose linkedEventId is absent from the lookup loose (filter-then-cluster, REQ-008)', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_B })]; + const { clusters, loose } = splitYearLetters(letters, lookup); + expect(clusters).toHaveLength(0); + expect(loose).toHaveLength(1); + }); + + it('places each letter in exactly one place (REQ-007)', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [ + makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }), + makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: undefined }), + makeEntry({ kind: 'LETTER', documentId: 'd3', linkedEventId: EV_B }) + ]; + const { clusters, loose } = splitYearLetters(letters, lookup); + const clustered = clusters.flatMap((c) => c.letters.length).reduce((a, b) => a + b, 0); + expect(clustered + loose.length).toBe(3); + expect(clustered).toBe(1); + expect(loose).toHaveLength(2); + }); + + it('keeps clusters in first-seen order', () => { + const lookup = new Map([ + [EV_B, 'Front'], + [EV_A, 'Stadtbrand'] + ]); + const letters = [ + makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }), + makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: EV_B }) + ]; + const { clusters } = splitYearLetters(letters, lookup); + expect(clusters.map((c) => c.eventId)).toEqual([EV_A, EV_B]); + }); +}); diff --git a/frontend/src/lib/timeline/eventClustering.ts b/frontend/src/lib/timeline/eventClustering.ts new file mode 100644 index 00000000..379bcbf2 --- /dev/null +++ b/frontend/src/lib/timeline/eventClustering.ts @@ -0,0 +1,74 @@ +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +/** Letters shown inside an event card before a "show more" toggle appears (#850, REQ-003). */ +export const CLUSTER_PREVIEW = 5; + +/** One contained event card's worth of letters within a year band (#850). */ +export interface EventCluster { + /** The curated event's id — also the `{#each}` key. */ + eventId: string; + /** The curated event's title (from the event lookup). */ + title: string; + letters: TimelineEntryDTO[]; +} + +/** The result of splitting a year's letters into event clusters and the loose remainder. */ +export interface SplitLetters { + clusters: EventCluster[]; + loose: TimelineEntryDTO[]; +} + +/** + * Maps each curated event present in the (already layer-filtered) timeline to its title. These + * are the only events a letter may cluster under — a letter whose `linkedEventId` is absent here + * links to an event the #780 layer filter removed, so it falls back to a loose chronological + * letter (filter-then-cluster, REQ-008). Curated events carry an `eventId`; derived life-events + * and letters do not, so they never enter the lookup. + */ +export function buildEventLookup(timeline: TimelineDTO): Map { + const lookup = new Map(); + const collect = (entries: TimelineEntryDTO[]) => { + for (const entry of entries) { + if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? ''); + } + }; + for (const band of timeline.years) collect(band.entries); + collect(timeline.undated); + return lookup; +} + +/** + * Splits one year's `LETTER` entries into event clusters and the loose remainder. A letter joins + * the cluster keyed by its `linkedEventId` IFF that id is set AND present in `eventLookup` + * (filter-then-cluster, REQ-007/008); every other letter is loose and stays in the chronological + * flow (REQ-006). Clusters keep first-seen order; each letter appears in exactly one place. + */ +export function splitYearLetters( + letters: TimelineEntryDTO[], + eventLookup: Map +): SplitLetters { + const byEvent = new Map(); + const clusters: EventCluster[] = []; + const loose: TimelineEntryDTO[] = []; + + for (const letter of letters) { + const eventId = letter.linkedEventId; + const title = eventId != null ? eventLookup.get(eventId) : undefined; + if (eventId != null && title !== undefined) { + let cluster = byEvent.get(eventId); + if (!cluster) { + cluster = { eventId, title, letters: [] }; + byEvent.set(eventId, cluster); + clusters.push(cluster); + } + cluster.letters.push(letter); + } else { + loose.push(letter); + } + } + + return { clusters, loose }; +}