feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847
@@ -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 });
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -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
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
YearLetterStripviaisDense(letters.length). Thisgroupedbranch bypasses that entirely — every loose letter renders as a fullLetterCardinside 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.