diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte
new file mode 100644
index 00000000..e1732816
--- /dev/null
+++ b/frontend/src/lib/timeline/TimelineView.svelte
@@ -0,0 +1,90 @@
+
+
+{#if isEmpty}
+
{m.timeline_empty_state()}
+{:else}
+
+
+ {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
+ -
+ {#if row.t === 'band'}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
+ {#if timeline.undated.length > 0}
+
+ {m.timeline_undated_section()}
+
+ {#each timeline.undated as entry (entry.documentId ?? entry.title)}
+
+ {/each}
+
+
+ {/if}
+{/if}
+
+
diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts
new file mode 100644
index 00000000..6d6422b3
--- /dev/null
+++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts
@@ -0,0 +1,173 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import TimelineView from './TimelineView.svelte';
+import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
+
+afterEach(() => cleanup());
+
+describe('TimelineView', () => {
+ it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => {
+ render(TimelineView, { timeline: makeTimelineDTO() });
+ expect(document.body.textContent).toContain('Noch keine Ereignisse.');
+ expect(document.querySelector('ol')).toBeNull();
+ expect(document.querySelector('section')).toBeNull();
+ });
+
+ it('renders the timeline as a single with each band a , ascending (REQ-006)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1914, [makeEntry({ documentId: 'a' })]),
+ makeYear(1916, [makeEntry({ documentId: 'b' })])
+ ]
+ })
+ });
+ expect(document.querySelectorAll('ol')).toHaveLength(1);
+ const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent);
+ expect(headings.some((t) => t?.includes('1914'))).toBe(true);
+ const order = headings.map((t) => t?.trim());
+ expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916'));
+ });
+
+ it('folds an interior run of empty years into one GapSpan (REQ-015)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1909, [makeEntry({ documentId: 'a' })]),
+ makeYear(1915, [makeEntry({ documentId: 'b' })])
+ ]
+ })
+ });
+ expect(document.body.textContent).toContain('1910–1914');
+ expect(document.body.textContent).toContain('keine Einträge');
+ });
+
+ it('folds a single empty interior year as a single year (REQ-015)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1911, [makeEntry({ documentId: 'a' })]),
+ makeYear(1913, [makeEntry({ documentId: 'b' })])
+ ]
+ })
+ });
+ expect(document.body.textContent).toContain('1912');
+ expect(document.body.textContent).not.toContain('1912–1912');
+ });
+
+ it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])],
+ undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
+ })
+ });
+ expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
+ expect(document.body.textContent).toContain('Ohne Datum');
+ });
+
+ it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] })
+ });
+ expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
+ });
+
+ it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1914, [makeEntry({ documentId: 'a' })]),
+ makeYear(1915, [makeEntry({ documentId: 'b' })])
+ ],
+ undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
+ }),
+ personId: undefined
+ });
+ // Two year bands inside the , plus the separate undated section.
+ expect(document.querySelectorAll('ol section h2')).toHaveLength(2);
+ expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
+ });
+
+ it('renders two derived events in one band without key collision (no-double-null-key)', () => {
+ const a = makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType: 'BIRTH',
+ title: 'Geburt: Anna',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined,
+ eventId: undefined,
+ linkedPersonIds: ['p1']
+ });
+ const b = makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType: 'BIRTH',
+ title: 'Geburt: Bertha',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined,
+ eventId: undefined,
+ linkedPersonIds: ['p2']
+ });
+ render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) });
+ expect(document.body.textContent).toContain('Geburt: Anna');
+ expect(document.body.textContent).toContain('Geburt: Bertha');
+ });
+
+ it('shows the redundant non-color cue label for each layer (REQ-018)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1914, [
+ makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType: 'BIRTH',
+ title: 'Geburt: Hans',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ }),
+ makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'PERSONAL',
+ eventId: 'e1',
+ title: 'Auswanderung',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ }),
+ makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'HISTORICAL',
+ precision: 'RANGE',
+ eventDate: '1914-01-01',
+ eventDateEnd: '1918-12-31',
+ title: 'Erster Weltkrieg',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ ])
+ ]
+ })
+ });
+ expect(document.body.textContent).toContain('Weltgeschehen');
+ expect(document.body.textContent).toContain('Familie');
+ expect(document.body.textContent).toContain('Geburt');
+ });
+
+ it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
+ const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
+ render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
+ const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) =>
+ el.getAttribute('data-side')
+ );
+ expect(sides).toEqual(['left', 'right', 'left', 'right']);
+ });
+});