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}`)} +
  1. + {#if row.t === 'band'} + + {:else} + + {/if} +
  2. + {/each} +
+ + {#if timeline.undated.length > 0} +
+

{m.timeline_undated_section()}

+ +
+ {/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']); + }); +});