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
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:
@@ -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<Row[]>(() => {
|
||||
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 });
|
||||
}
|
||||
// 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 });
|
||||
}
|
||||
for (const bucket of bucketLetters(letters, bucketMode, eventLookup)) {
|
||||
out.push({ t: 'bucket', bucket });
|
||||
}
|
||||
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 {
|
||||
<LetterCard entry={row.entry} />
|
||||
</div>
|
||||
{:else if row.t === 'bucket'}
|
||||
<LetterBucket bucket={row.bucket} mode={bucketMode} />
|
||||
<LetterBucket bucket={row.bucket} mode={bucketMode} year={year.year} nested={row.nested} />
|
||||
{:else}
|
||||
<YearLetterStrip letters={letters} year={year.year} />
|
||||
{/if}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user