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:
90
frontend/src/lib/timeline/TimelineView.svelte
Normal file
90
frontend/src/lib/timeline/TimelineView.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import YearBand from './YearBand.svelte';
|
||||||
|
import GapSpan from './GapSpan.svelte';
|
||||||
|
import LetterCard from './LetterCard.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||||
|
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates the global timeline (REQ-001/003). Renders the year bands the DTO
|
||||||
|
* delivers in order — never re-sorting — interleaving a folded GapSpan for each
|
||||||
|
* interior run of empty years (REQ-015), then the undated bucket (REQ-016). An
|
||||||
|
* 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 <main> — the layout does.
|
||||||
|
*/
|
||||||
|
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
|
||||||
|
|
||||||
|
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
|
||||||
|
|
||||||
|
const rows = $derived.by<Row[]>(() => {
|
||||||
|
const out: Row[] = [];
|
||||||
|
const years = timeline.years;
|
||||||
|
for (let i = 0; i < years.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
const prev = years[i - 1].year;
|
||||||
|
const cur = years[i].year;
|
||||||
|
if (cur - prev > 1) out.push({ t: 'gap', from: prev + 1, to: cur - 1 });
|
||||||
|
}
|
||||||
|
out.push({ t: 'band', year: years[i] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length === 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isEmpty}
|
||||||
|
<p class="py-12 text-center font-serif text-base text-ink-2">{m.timeline_empty_state()}</p>
|
||||||
|
{:else}
|
||||||
|
<!-- personId is a declared seam for the per-person Lebensweg rail (issue #10);
|
||||||
|
undefined in the global view, surfaced only on the root, never passed to
|
||||||
|
leaf cards (REQ-025). -->
|
||||||
|
<ol class="timeline-axis relative mx-auto max-w-3xl" data-person-id={personId}>
|
||||||
|
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
||||||
|
<li>
|
||||||
|
{#if row.t === 'band'}
|
||||||
|
<YearBand year={row.year} />
|
||||||
|
{:else}
|
||||||
|
<GapSpan from={row.from} to={row.to} />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{#if timeline.undated.length > 0}
|
||||||
|
<section data-testid="undated-section" class="mx-auto mt-8 max-w-3xl">
|
||||||
|
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">{m.timeline_undated_section()}</h2>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each timeline.undated as entry (entry.documentId ?? entry.title)}
|
||||||
|
<li><LetterCard entry={entry} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
|
||||||
|
centered spine the bands' alternating cards sit on either side of. The
|
||||||
|
spine is decorative — the chronology lives in the <ol> DOM order. */
|
||||||
|
.timeline-axis::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0.5rem;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(var(--palette-mint), var(--palette-navy));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.timeline-axis::before {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
173
frontend/src/lib/timeline/TimelineView.svelte.spec.ts
Normal file
173
frontend/src/lib/timeline/TimelineView.svelte.spec.ts
Normal 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('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 <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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user