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'); + }); });