From 4b11d66ca533e9660850f6ad0c266535ea528679 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 10:31:03 +0200 Subject: [PATCH] feat(timeline): add the client-side letter regroup transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure module powering the #827 Datum·Ereignis·Thema toggle: buildEventLookup (curated events that survived the #780 layer filter), hasLooseLetters (the control's enabled state), and bucketLetters (cluster loose letters by linkedEventId or primary root tag, with a "Weitere Briefe"/"Ohne Thema" fallback). Filter-then-group, no refetch. Refs #827 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/timeline/timelineGrouping.spec.ts | 157 ++++++++++++++++++ frontend/src/lib/timeline/timelineGrouping.ts | 126 ++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 frontend/src/lib/timeline/timelineGrouping.spec.ts create mode 100644 frontend/src/lib/timeline/timelineGrouping.ts diff --git a/frontend/src/lib/timeline/timelineGrouping.spec.ts b/frontend/src/lib/timeline/timelineGrouping.spec.ts new file mode 100644 index 00000000..7a050ec4 --- /dev/null +++ b/frontend/src/lib/timeline/timelineGrouping.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { buildEventLookup, bucketLetters, hasLooseLetters } from './timelineGrouping'; +import { makeEntry, makeYear, makeTimelineDTO } from './test-factories'; + +// Entry factories pinned to the shapes the grouping transform discriminates (#827). +const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides }); + +const curatedEvent = (id: string, title: string, overrides = {}) => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + documentId: undefined, + eventId: id, + title, + senderName: '', + receiverName: '', + ...overrides + }); + +describe('buildEventLookup (REQ-019)', () => { + it('collects curated events (eventId set) from year bands and the undated bucket', () => { + const dto = makeTimelineDTO({ + years: [makeYear(1915, [curatedEvent('e1', 'Briefe von der Front'), letter()])], + undated: [curatedEvent('e2', 'Unbekanntes Ereignis')] + }); + const lookup = buildEventLookup(dto); + expect(lookup.get('e1')).toBe('Briefe von der Front'); + expect(lookup.get('e2')).toBe('Unbekanntes Ereignis'); + expect(lookup.size).toBe(2); + }); + + it('ignores letters and derived life-events (no eventId)', () => { + const dto = makeTimelineDTO({ + years: [ + makeYear(1915, [ + letter({ linkedEventId: 'e1' }), + makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: true, eventId: undefined }) + ]) + ] + }); + expect(buildEventLookup(dto).size).toBe(0); + }); +}); + +describe('hasLooseLetters (REQ-018)', () => { + it('is true when a year band or the undated bucket holds a letter', () => { + expect(hasLooseLetters(makeTimelineDTO({ years: [makeYear(1915, [letter()])] }))).toBe(true); + expect(hasLooseLetters(makeTimelineDTO({ undated: [letter({ documentId: 'u1' })] }))).toBe( + true + ); + }); + + it('is false when only events remain', () => { + const dto = makeTimelineDTO({ years: [makeYear(1915, [curatedEvent('e1', 'Ereignis')])] }); + expect(hasLooseLetters(dto)).toBe(false); + }); +}); + +describe('bucketLetters — Ereignis mode (REQ-003/006/019)', () => { + const lookup = new Map([ + ['e1', 'Briefe von der Front'], + ['e2', 'Weihnachten 1915'] + ]); + + it('clusters letters under the curated event named by linkedEventId, with matching counts', () => { + const letters = [ + letter({ documentId: 'a', linkedEventId: 'e1' }), + letter({ documentId: 'b', linkedEventId: 'e1' }), + letter({ documentId: 'c', linkedEventId: 'e2' }) + ]; + const buckets = bucketLetters(letters, 'event', lookup); + const front = buckets.find((b) => b.title === 'Briefe von der Front'); + expect(front?.kind).toBe('event'); + expect(front?.letters).toHaveLength(2); + expect(buckets.find((b) => b.title === 'Weihnachten 1915')?.letters).toHaveLength(1); + }); + + it('drops a letter with no linkedEventId into the fallback bucket (REQ-006)', () => { + const letters = [letter({ documentId: 'a', linkedEventId: undefined })]; + const buckets = bucketLetters(letters, 'event', lookup); + expect(buckets).toHaveLength(1); + expect(buckets[0].kind).toBe('fallback'); + expect(buckets[0].letters).toHaveLength(1); + }); + + it('drops a letter whose linked event is absent from the lookup into fallback (REQ-019)', () => { + // e9 is not in the filtered view (its layer was toggled off) → no cluster. + const letters = [letter({ documentId: 'a', linkedEventId: 'e9' })]; + const buckets = bucketLetters(letters, 'event', lookup); + expect(buckets).toHaveLength(1); + expect(buckets[0].kind).toBe('fallback'); + }); + + it('keeps the fallback bucket last', () => { + const letters = [ + letter({ documentId: 'a', linkedEventId: undefined }), + letter({ documentId: 'b', linkedEventId: 'e1' }) + ]; + const buckets = bucketLetters(letters, 'event', lookup); + expect(buckets[buckets.length - 1].kind).toBe('fallback'); + }); +}); + +describe('bucketLetters — Thema mode (REQ-004/007/008)', () => { + const noEvents = new Map(); + + it('buckets letters under their primary root tag with name and colour', () => { + const letters = [ + letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }), + letter({ documentId: 'b', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }), + letter({ + documentId: 'c', + rootTagId: 't2', + rootTagName: 'Weihnachten', + rootTagColor: 'amber' + }) + ]; + const buckets = bucketLetters(letters, 'thema', noEvents); + const krieg = buckets.find((b) => b.title === 'Krieg'); + expect(krieg?.kind).toBe('tag'); + expect(krieg?.color).toBe('sienna'); + expect(krieg?.letters).toHaveLength(2); + expect(buckets.find((b) => b.title === 'Weihnachten')?.color).toBe('amber'); + }); + + it('drops an untagged letter into the "Ohne Thema" fallback bucket (REQ-007)', () => { + const letters = [letter({ documentId: 'a', rootTagId: undefined })]; + const buckets = bucketLetters(letters, 'thema', noEvents); + expect(buckets).toHaveLength(1); + expect(buckets[0].kind).toBe('fallback'); + expect(buckets[0].color).toBeNull(); + }); + + it('places a letter in exactly one bucket (REQ-008)', () => { + const letters = [ + letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }) + ]; + const buckets = bucketLetters(letters, 'thema', noEvents); + const occurrences = buckets.flatMap((b) => b.letters).filter((l) => l.documentId === 'a'); + expect(occurrences).toHaveLength(1); + }); + + it('carries a null colour through for a colourless root tag', () => { + const letters = [ + letter({ + documentId: 'a', + rootTagId: 't3', + rootTagName: 'Allgemein', + rootTagColor: undefined + }) + ]; + const buckets = bucketLetters(letters, 'thema', noEvents); + expect(buckets[0].kind).toBe('tag'); + expect(buckets[0].color).toBeNull(); + }); +}); diff --git a/frontend/src/lib/timeline/timelineGrouping.ts b/frontend/src/lib/timeline/timelineGrouping.ts new file mode 100644 index 00000000..13f317ac --- /dev/null +++ b/frontend/src/lib/timeline/timelineGrouping.ts @@ -0,0 +1,126 @@ +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +/** + * The three ways a reader can bundle the loose letters on `/zeitstrahl` (#827). The + * axis-fixed layers (life-events, event pills, world-bands) are identical in every mode + * — only loose-letter bundling changes. Grouping runs over the *already layer-filtered* + * timeline (#780): filter-then-group. + */ +export type GroupingMode = 'date' | 'event' | 'thema'; + +/** The default mode — chronological, as #779 shipped. */ +export const DEFAULT_GROUPING: GroupingMode = 'date'; + +/** + * One bundle of loose letters under a single header, within a year (Ereignis/Thema modes). + * `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized + * fallback ("Weitere Briefe" / "Ohne Thema") the view supplies for `kind === 'fallback'`. + */ +export interface LetterBucket { + /** Stable `{#each}` key, unique within a year's bucket list. */ + key: string; + kind: 'event' | 'tag' | 'fallback'; + /** Header label for `event`/`tag` buckets; absent for `fallback` (view supplies a localized label). */ + title?: string; + /** Root-tag colour token for a `tag` bucket; `null` for `event`/`fallback` (neutral). */ + color: string | null; + letters: TimelineEntryDTO[]; +} + +/** + * Maps each curated event present in the (already-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 layer filter removed, so it falls back to "Weitere Briefe" (filter-then-group, + * REQ-019). Curated events carry an `eventId`; derived life-events and letters do not. + */ +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; +} + +/** + * True when the timeline still holds at least one loose letter. Drives the grouping control's + * enabled state: with the Letters layer filtered off there is nothing to regroup (REQ-018). + */ +export function hasLooseLetters(timeline: TimelineDTO): boolean { + const holdsLetter = (entries: TimelineEntryDTO[]) => entries.some((e) => e.kind === 'LETTER'); + return timeline.years.some((band) => holdsLetter(band.entries)) || holdsLetter(timeline.undated); +} + +/** + * Buckets one year's loose letters for Ereignis/Thema mode. The caller passes only that year's + * `LETTER` entries; events stay on the axis untouched (REQ-001). Buckets keep first-seen order and + * the fallback bucket, if any, always sorts last. + * + * - `event`: cluster under `linkedEventId` when it is set AND survives in `eventLookup`; otherwise + * the fallback "Weitere Briefe" bucket (REQ-003/006/019). + * - `thema`: bucket under `rootTagId` (header = `rootTagName`, tint = `rootTagColor`); an untagged + * letter goes to the fallback "Ohne Thema" bucket (REQ-004/007). A letter carries exactly one + * `rootTagId`, so it lands in exactly one bucket (REQ-008). + */ +export function bucketLetters( + letters: TimelineEntryDTO[], + mode: Exclude, + eventLookup: Map +): LetterBucket[] { + const byKey = new Map(); + let fallback: LetterBucket | null = null; + + const fallbackBucket = (): LetterBucket => { + if (!fallback) fallback = { key: '__fallback__', kind: 'fallback', color: null, letters: [] }; + return fallback; + }; + + const namedBucket = (id: string, build: () => LetterBucket): LetterBucket => { + let bucket = byKey.get(id); + if (!bucket) { + bucket = build(); + byKey.set(id, bucket); + } + return bucket; + }; + + for (const letter of letters) { + if (mode === 'event') { + const id = letter.linkedEventId; + if (id && eventLookup.has(id)) { + namedBucket(id, () => ({ + key: `event:${id}`, + kind: 'event', + title: eventLookup.get(id), + color: null, + letters: [] + })).letters.push(letter); + } else { + fallbackBucket().letters.push(letter); + } + } else { + const id = letter.rootTagId; + if (id) { + namedBucket(id, () => ({ + key: `tag:${id}`, + kind: 'tag', + title: letter.rootTagName ?? '', + color: letter.rootTagColor ?? null, + letters: [] + })).letters.push(letter); + } else { + fallbackBucket().letters.push(letter); + } + } + } + + const buckets = [...byKey.values()]; + if (fallback) buckets.push(fallback); + return buckets; +}