feat(timeline): nest Ereignis letters under their event pill, no duplicate title
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m47s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m56s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 34s
SDD Gate / Constitution Impact (pull_request) Successful in 19s

In Ereignis mode the curated event showed twice — once as its axis pill and again
as a repeated "✉ <event>" 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
This commit is contained in:
Marcel
2026-06-15 12:07:39 +02:00
parent 23534fb077
commit ea1034f9ce
2 changed files with 75 additions and 9 deletions

View File

@@ -43,26 +43,53 @@ type Row =
| { t: 'event'; entry: TimelineEntryDTO } | { t: 'event'; entry: TimelineEntryDTO }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' } | { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' } | { t: 'strip' }
| { t: 'bucket'; bucket: LetterBucketModel }; | { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER')); const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
const dense = $derived(isDense(letters.length)); const dense = $derived(isDense(letters.length));
const grouped = $derived(groupingMode !== 'date');
const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event'); const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const out: Row[] = []; const out: Row[] = [];
if (grouped) {
// Events stay on the axis, identical to Datum mode (REQ-001); only the loose // Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly
// letters re-bundle into per-year buckets below them (REQ-003/004). // 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) { 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)) { // Clusters whose pill is in another band keep their header; then the fallback, last.
out.push({ t: 'bucket', bucket }); for (const bucket of buckets) {
if (bucket.kind === 'fallback' || !hasPill(bucket.key)) {
out.push({ t: 'bucket', bucket, nested: false });
}
} }
return out; 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 stripInserted = false;
let letterIndex = 0; let letterIndex = 0;
for (const entry of year.entries) { for (const entry of year.entries) {
@@ -110,7 +137,7 @@ function rowKey(row: Row): string {
<LetterCard entry={row.entry} /> <LetterCard entry={row.entry} />
</div> </div>
{:else if row.t === 'bucket'} {:else if row.t === 'bucket'}
<LetterBucket bucket={row.bucket} mode={bucketMode} /> <LetterBucket bucket={row.bucket} mode={bucketMode} year={year.year} nested={row.nested} />
{:else} {:else}
<YearLetterStrip letters={letters} year={year.year} /> <YearLetterStrip letters={letters} year={year.year} />
{/if} {/if}

View File

@@ -221,4 +221,43 @@ describe('YearBand — grouping modes (#827)', () => {
const chip = document.querySelector('[data-testid="bucket-header-chip"]'); const chip = document.querySelector('[data-testid="bucket-header-chip"]');
expect(chip?.textContent).toContain('Krieg'); 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');
});
}); });