feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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.