feat(timeline): render letter buckets in TimelineView/YearBand

Thread groupingMode through TimelineView → YearBand. TimelineView resolves the
event lookup once over the filtered view (so Ereignis clusters never reference a
filtered-out event). In non-Datum modes YearBand keeps its event pills/world-bands
identical (REQ-001) and replaces the loose letters with per-year LetterBuckets
(REQ-002/003/004); Datum keeps the original card/strip path. The undated bucket is
unchanged in every mode.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 10:52:48 +02:00
parent bc22b2d4c9
commit 8be4b40e54
3 changed files with 128 additions and 6 deletions

View File

@@ -3,8 +3,14 @@ import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import LetterBucket from './LetterBucket.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import {
bucketLetters,
type GroupingMode,
type LetterBucket as LetterBucketModel
} from './timelineGrouping';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
@@ -15,19 +21,48 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* render in DTO order as pills/bands; letters render as individual cards while
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
*
* In Ereignis/Thema mode (#827) the event pills/world-bands render identically
* (REQ-001); only the loose letters re-bundle into per-year buckets below them
* (REQ-002/003/004). Datum mode is the original individual-card / density-strip
* path, untouched.
*/
let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props();
let {
year,
canWrite = false,
groupingMode = 'date',
eventLookup = new Map<string, string>()
}: {
year: TimelineYearDTO;
canWrite?: boolean;
groupingMode?: GroupingMode;
eventLookup?: Map<string, string>;
} = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' };
| { t: 'strip' }
| { t: 'bucket'; bucket: LetterBucketModel };
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).
for (const entry of year.entries) {
if (entry.kind === 'EVENT') out.push({ t: 'event', entry });
}
for (const bucket of bucketLetters(letters, bucketMode, eventLookup)) {
out.push({ t: 'bucket', bucket });
}
return out;
}
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) {
@@ -43,6 +78,12 @@ const rows = $derived.by<Row[]>(() => {
}
return out;
});
function rowKey(row: Row): string {
if (row.t === 'strip') return `strip-${year.year}`;
if (row.t === 'bucket') return row.bucket.key;
return entryKey(row.entry);
}
</script>
<section class="py-2">
@@ -56,7 +97,7 @@ const rows = $derived.by<Row[]>(() => {
</h2>
<div class="mt-3 space-y-3">
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
{#each rows as row (rowKey(row))}
{#if row.t === 'event'}
{#if row.entry.type === 'HISTORICAL'}
<WorldBand entry={row.entry} canWrite={canWrite} />
@@ -68,6 +109,8 @@ const rows = $derived.by<Row[]>(() => {
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
<LetterCard entry={row.entry} />
</div>
{:else if row.t === 'bucket'}
<LetterBucket bucket={row.bucket} mode={bucketMode} />
{:else}
<YearLetterStrip letters={letters} year={year.year} />
{/if}