diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte index c3008e90..aac64896 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -6,6 +6,7 @@ import LetterCard from './LetterCard.svelte'; import EventPill from './EventPill.svelte'; import WorldBand from './WorldBand.svelte'; import { entryKey } from './entryKey'; +import { buildEventLookup, type GroupingMode } from './timelineGrouping'; import type { components } from '$lib/generated/api'; type TimelineDTO = components['schemas']['TimelineDTO']; @@ -18,12 +19,28 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO']; * empty timeline shows a calm message (REQ-017). `personId` is a declared seam * for the per-person rail (issue #10) and is undefined here; it is not passed to * leaf cards (REQ-025). Owns no
— the layout does. + * + * `groupingMode` (#827) flows down to each YearBand to re-bundle its loose letters; + * the event lookup — the curated events present in this (already layer-filtered) + * view — is resolved once here so Ereignis clusters never reference a filtered-out + * event (filter-then-group, REQ-019). The undated bucket renders unchanged in every + * mode (its letters have no year, so the per-year bucketing does not apply). */ let { timeline, personId = undefined, - canWrite = false -}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props(); + canWrite = false, + groupingMode = 'date' +}: { + timeline: TimelineDTO; + personId?: string; + canWrite?: boolean; + groupingMode?: GroupingMode; +} = $props(); + +const eventLookup = $derived( + groupingMode === 'date' ? new Map() : buildEventLookup(timeline) +); type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number }; @@ -54,7 +71,12 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
  • {#if row.t === 'band'} - + {:else} {/if} diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index fafa0b4c..d60012bc 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -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() +}: { + year: TimelineYearDTO; + canWrite?: boolean; + groupingMode?: GroupingMode; + eventLookup?: Map; +} = $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(() => { 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(() => { } 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); +}
    @@ -56,7 +97,7 @@ const rows = $derived.by(() => {
    - {#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'} @@ -68,6 +109,8 @@ const rows = $derived.by(() => {
    + {:else if row.t === 'bucket'} + {:else} {/if} diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 45845080..2481c6a2 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -165,3 +165,60 @@ describe('YearBand', () => { } }); }); + +describe('YearBand — grouping modes (#827)', () => { + it('keeps individual letter cards and no buckets in Datum mode (default)', () => { + render(YearBand, { year: makeYear(1915, manyLetters(1915, 3)) }); + expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull(); + expect(document.querySelectorAll('a')).toHaveLength(3); + }); + + it('clusters loose letters under their linked event in Ereignis mode (REQ-002/003)', () => { + const a = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1915-03-01' }); + const b = makeEntry({ documentId: 'b', linkedEventId: 'e1', eventDate: '1915-04-01' }); + render(YearBand, { + year: makeYear(1915, [a, b]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Briefe von der Front']]) + }); + expect(document.querySelectorAll('[data-testid="letter-bucket"]')).toHaveLength(1); + expect(document.body.textContent).toContain('Briefe von der Front'); + // no alternating individual letter rows in grouped mode + expect(document.querySelector('.letter-row')).toBeNull(); + }); + + it('still renders the event world-band in Ereignis mode (REQ-001)', () => { + const band = makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + precision: 'RANGE', + eventDate: '1914-01-01', + eventDateEnd: '1918-12-31', + title: 'Erster Weltkrieg', + senderName: '', + receiverName: '', + documentId: undefined + }); + const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1914-05-01' }); + render(YearBand, { + year: makeYear(1914, [band, letter]), + groupingMode: 'event', + eventLookup: new Map([['e1', 'Front']]) + }); + expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull(); + expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull(); + }); + + it('buckets loose letters under their root tag in Thema mode (REQ-004)', () => { + const a = makeEntry({ + documentId: 'a', + rootTagId: 't1', + rootTagName: 'Krieg', + rootTagColor: 'sienna', + eventDate: '1915-03-01' + }); + render(YearBand, { year: makeYear(1915, [a]), groupingMode: 'thema', eventLookup: new Map() }); + const chip = document.querySelector('[data-testid="bucket-header-chip"]'); + expect(chip?.textContent).toContain('Krieg'); + }); +});