From ea1034f9cec1052402cf7e5593326d8e2c633d37 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 12:07:39 +0200 Subject: [PATCH] feat(timeline): nest Ereignis letters under their event pill, no duplicate title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Ereignis mode the curated event showed twice — once as its axis pill and again as a repeated "✉ " bucket header below. Letters that link to a curated event whose pill is in the same year band now nest directly under that pill (headerless), so the title reads once. A cluster whose pill lives in another band keeps its header, and unlinked letters still fall to the single "Weitere Briefe" bucket. Thema mode is unchanged (tags have no axis pill). REQ-001 holds — the pills render identically. Refs #827 --- frontend/src/lib/timeline/YearBand.svelte | 45 +++++++++++++++---- .../src/lib/timeline/YearBand.svelte.spec.ts | 39 ++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index d60012bc..17f704a4 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -43,26 +43,53 @@ type Row = | { t: 'event'; entry: TimelineEntryDTO } | { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' } | { t: 'strip' } - | { t: 'bucket'; bucket: LetterBucketModel }; + | { t: 'bucket'; bucket: LetterBucketModel; nested: boolean }; const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER')); const dense = $derived(isDense(letters.length)); -const grouped = $derived(groupingMode !== 'date'); const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event'); const rows = $derived.by(() => { const out: Row[] = []; - if (grouped) { - // Events stay on the axis, identical to Datum mode (REQ-001); only the loose - // letters re-bundle into per-year buckets below them (REQ-003/004). + + // Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly + // beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose + // pill lives in another year band (or was filtered out) keeps its own header here, and the + // unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019). + if (groupingMode === 'event') { + const buckets = bucketLetters(letters, 'event', eventLookup); + const hasPill = (bucketKey: string) => + year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey); + // Each pill renders, then its same-year cluster nests directly beneath it (no header). for (const entry of year.entries) { - if (entry.kind === 'EVENT') out.push({ t: 'event', entry }); + if (entry.kind !== 'EVENT') continue; + out.push({ t: 'event', entry }); + const bucket = entry.eventId + ? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`) + : undefined; + if (bucket) out.push({ t: 'bucket', bucket, nested: true }); } - for (const bucket of bucketLetters(letters, bucketMode, eventLookup)) { - out.push({ t: 'bucket', bucket }); + // Clusters whose pill is in another band keep their header; then the fallback, last. + for (const bucket of buckets) { + if (bucket.kind === 'fallback' || !hasPill(bucket.key)) { + out.push({ t: 'bucket', bucket, nested: false }); + } } return out; } + + // Thema: events stay on the axis (REQ-001); loose letters re-bundle into per-year root-tag + // buckets below them (REQ-004) — no axis pill exists for a tag, so every bucket keeps a header. + if (groupingMode === 'thema') { + for (const entry of year.entries) { + if (entry.kind === 'EVENT') out.push({ t: 'event', entry }); + } + for (const bucket of bucketLetters(letters, 'thema', eventLookup)) { + out.push({ t: 'bucket', bucket, nested: false }); + } + return out; + } + let stripInserted = false; let letterIndex = 0; for (const entry of year.entries) { @@ -110,7 +137,7 @@ function rowKey(row: Row): string { {:else if row.t === 'bucket'} - + {:else} {/if} diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 2481c6a2..9f2a8ece 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -221,4 +221,43 @@ describe('YearBand — grouping modes (#827)', () => { const chip = document.querySelector('[data-testid="bucket-header-chip"]'); expect(chip?.textContent).toContain('Krieg'); }); + + it('nests an event cluster under its pill in the same year without repeating the title (#827)', () => { + const pill = makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: 'e1', + title: 'Ein gewaltiger Stadtbrand', + eventDate: '1916-07-06', + senderName: '', + receiverName: '', + documentId: undefined + }); + const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1916-07-06' }); + render(YearBand, { + year: makeYear(1916, [pill, letter]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]) + }); + // the title appears exactly once — on the axis pill, NOT also as a bucket header + const occurrences = + (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1; + expect(occurrences).toBe(1); + // the letter is still clustered (nested under the pill) as the event-letter card + expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull(); + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + }); + + it('keeps a header on an event cluster whose pill is in another year (#827)', () => { + // the letter links to e1, but e1's pill lives in a different band — so the cluster + // keeps its own header here (no pill nearby to duplicate). + const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1917-02-01' }); + render(YearBand, { + year: makeYear(1917, [letter]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Briefe von der Front']]) + }); + expect(document.body.textContent).toContain('Briefe von der Front'); + }); });