feat(timeline): add YearBand (section + sticky h2, cards vs strip)
One <section> per year with a sticky <h2> at top:4rem (REQ-006). Events render in
DTO order as pills/bands; letters render as individual cards while <= 12 (REQ-011)
or collapse to one density strip above that (REQ-012); DTO order is never re-sorted
(REQ-003). Letters carry an alternating data-side for the centered desktop axis
(REQ-004); single left column on phone (REQ-005). Derived-safe {#each} key.
Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
108
frontend/src/lib/timeline/YearBand.svelte
Normal file
108
frontend/src/lib/timeline/YearBand.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import EventPill from './EventPill.svelte';
|
||||
import WorldBand from './WorldBand.svelte';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import { isDense } from './timelineDensity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
|
||||
* 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).
|
||||
*/
|
||||
let { year }: { year: TimelineYearDTO } = $props();
|
||||
|
||||
type Row =
|
||||
| { t: 'event'; entry: TimelineEntryDTO }
|
||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
||||
| { t: 'strip' };
|
||||
|
||||
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
|
||||
const dense = $derived(isDense(letters.length));
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
let stripInserted = false;
|
||||
let letterIndex = 0;
|
||||
for (const entry of year.entries) {
|
||||
if (entry.kind === 'EVENT') {
|
||||
out.push({ t: 'event', entry });
|
||||
} else if (!dense) {
|
||||
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
|
||||
letterIndex += 1;
|
||||
} else if (!stripInserted) {
|
||||
out.push({ t: 'strip' });
|
||||
stripInserted = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function entryKey(entry: TimelineEntryDTO): string {
|
||||
return (
|
||||
entry.kind +
|
||||
':' +
|
||||
(entry.eventId ??
|
||||
entry.documentId ??
|
||||
`${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-2">
|
||||
<h2
|
||||
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
|
||||
>
|
||||
{year.year}
|
||||
</h2>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
|
||||
{#if row.t === 'event'}
|
||||
{#if row.entry.type === 'HISTORICAL'}
|
||||
<WorldBand entry={row.entry} />
|
||||
{:else}
|
||||
<EventPill entry={row.entry} />
|
||||
{/if}
|
||||
{:else if row.t === 'letter'}
|
||||
<div class="letter-row" data-side={row.side}>
|
||||
<LetterCard entry={row.entry} />
|
||||
</div>
|
||||
{:else}
|
||||
<YearLetterStrip letters={letters} year={year.year} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
|
||||
header is a 64px sticky nav). REQ-006. */
|
||||
.year-heading {
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Phone (< 1024px): single left-anchored column, all letters on one side
|
||||
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
|
||||
so consecutive cards sit on opposite sides of the spine (REQ-004). */
|
||||
@media (min-width: 1024px) {
|
||||
.letter-row {
|
||||
width: 50%;
|
||||
}
|
||||
.letter-row[data-side='left'] {
|
||||
margin-right: auto;
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
.letter-row[data-side='right'] {
|
||||
margin-left: auto;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/lib/timeline/YearBand.svelte.spec.ts
Normal file
84
frontend/src/lib/timeline/YearBand.svelte.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import YearBand from './YearBand.svelte';
|
||||
import { makeEntry, makeYear } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function manyLetters(year: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` })
|
||||
);
|
||||
}
|
||||
|
||||
describe('YearBand', () => {
|
||||
it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => {
|
||||
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
||||
const section = document.querySelector('section');
|
||||
expect(section).not.toBeNull();
|
||||
const h2 = section?.querySelector('h2');
|
||||
expect(h2?.textContent).toContain('1914');
|
||||
const cs = getComputedStyle(h2 as HTMLElement);
|
||||
expect(cs.position).toBe('sticky');
|
||||
expect(cs.top).toBe('64px');
|
||||
});
|
||||
|
||||
it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => {
|
||||
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
|
||||
expect(document.querySelectorAll('a')).toHaveLength(3);
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a single strip when the band holds > 12 letters (REQ-012)', () => {
|
||||
render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) });
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
|
||||
// collapsed: no individual letter links yet
|
||||
expect(document.querySelectorAll('a')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => {
|
||||
const dayLetter = makeEntry({
|
||||
precision: 'DAY',
|
||||
eventDate: '1923-04-12',
|
||||
title: 'Tagesgenau',
|
||||
documentId: 'day'
|
||||
});
|
||||
const yearLetter = makeEntry({
|
||||
precision: 'YEAR',
|
||||
eventDate: '1923-01-01',
|
||||
title: 'Nur Jahr',
|
||||
documentId: 'year'
|
||||
});
|
||||
render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) });
|
||||
const links = Array.from(document.querySelectorAll('a'));
|
||||
expect(links[0].getAttribute('href')).toBe('/documents/day');
|
||||
expect(links[1].getAttribute('href')).toBe('/documents/year');
|
||||
});
|
||||
|
||||
it('renders an EVENT as a pill and a HISTORICAL event as a band', () => {
|
||||
const pill = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'MARRIAGE',
|
||||
title: 'Heirat',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
const band = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'HISTORICAL',
|
||||
precision: 'RANGE',
|
||||
eventDate: '1914-01-01',
|
||||
eventDateEnd: '1918-12-31',
|
||||
title: 'Erster Weltkrieg',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
render(YearBand, { year: makeYear(1914, [pill, band]) });
|
||||
expect(document.body.textContent).toContain('Heirat');
|
||||
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user