feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847

Closed
marcel wants to merge 25 commits from feat/issue-827-zeitstrahl-grouping into main
2 changed files with 75 additions and 9 deletions
Showing only changes of commit ea1034f9ce - Show all commits

View File

@@ -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) =>

The dense-year density-strip safety valve (REQ-011/012) is silently dropped in Ereignis/Thema mode. In Datum mode a year with >12 letters collapses to a single YearLetterStrip via isDense(letters.length). This grouped branch bypasses that entirely — every loose letter renders as a full LetterCard inside its bucket with no upper bound. A year with, say, 200 letters all under one curated event becomes a wall of 200 cards in Ereignis mode (same in Thema), reintroducing exactly the scannability/perf problem the density strip was built to prevent. Worth confirming this is intended, or applying a per-bucket density cap.

**The dense-year density-strip safety valve (REQ-011/012) is silently dropped in Ereignis/Thema mode.** In Datum mode a year with >12 letters collapses to a single `YearLetterStrip` via `isDense(letters.length)`. This `grouped` branch bypasses that entirely — every loose letter renders as a full `LetterCard` inside its bucket with no upper bound. A year with, say, 200 letters all under one curated event becomes a wall of 200 cards in Ereignis mode (same in Thema), reintroducing exactly the scannability/perf problem the density strip was built to prevent. Worth confirming this is intended, or applying a per-bucket density cap.
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 {
<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}

View File

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