feat(timeline): add TimelineView orchestrator

Renders year bands in DTO order with interior empty-year runs folded into one
GapSpan (REQ-015), a single <ol> in chronological DOM order (REQ-006), the undated
bucket via {#if} (REQ-016), and a calm empty state (REQ-017). personId is a
declared seam (issue #10), undefined here, never passed to leaf cards (REQ-025).
Centered desktop spine / left phone spine via scoped CSS. Owns no <main>.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 19:44:05 +02:00
parent f9ddcf0374
commit 588314f862
2 changed files with 263 additions and 0 deletions

View File

@@ -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 <ol> with each band a <section>, 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('19101914');
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('19121912');
});
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 <ol>, 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']);
});
});