feat(timeline): render letter buckets in TimelineView/YearBand
Thread groupingMode through TimelineView → YearBand. TimelineView resolves the event lookup once over the filtered view (so Ereignis clusters never reference a filtered-out event). In non-Datum modes YearBand keeps its event pills/world-bands identical (REQ-001) and replaces the loose letters with per-year LetterBuckets (REQ-002/003/004); Datum keeps the original card/strip path. The undated bucket is unchanged in every mode. Refs #827 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import LetterCard from './LetterCard.svelte';
|
||||
import EventPill from './EventPill.svelte';
|
||||
import WorldBand from './WorldBand.svelte';
|
||||
import { entryKey } from './entryKey';
|
||||
import { buildEventLookup, type GroupingMode } from './timelineGrouping';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||
@@ -18,12 +19,28 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||
* 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.
|
||||
*
|
||||
* `groupingMode` (#827) flows down to each YearBand to re-bundle its loose letters;
|
||||
* the event lookup — the curated events present in this (already layer-filtered)
|
||||
* view — is resolved once here so Ereignis clusters never reference a filtered-out
|
||||
* event (filter-then-group, REQ-019). The undated bucket renders unchanged in every
|
||||
* mode (its letters have no year, so the per-year bucketing does not apply).
|
||||
*/
|
||||
let {
|
||||
timeline,
|
||||
personId = undefined,
|
||||
canWrite = false
|
||||
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
|
||||
canWrite = false,
|
||||
groupingMode = 'date'
|
||||
}: {
|
||||
timeline: TimelineDTO;
|
||||
personId?: string;
|
||||
canWrite?: boolean;
|
||||
groupingMode?: GroupingMode;
|
||||
} = $props();
|
||||
|
||||
const eventLookup = $derived(
|
||||
groupingMode === 'date' ? new Map<string, string>() : buildEventLookup(timeline)
|
||||
);
|
||||
|
||||
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
|
||||
|
||||
@@ -54,7 +71,12 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
||||
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
||||
<li>
|
||||
{#if row.t === 'band'}
|
||||
<YearBand year={row.year} canWrite={canWrite} />
|
||||
<YearBand
|
||||
year={row.year}
|
||||
canWrite={canWrite}
|
||||
groupingMode={groupingMode}
|
||||
eventLookup={eventLookup}
|
||||
/>
|
||||
{:else}
|
||||
<GapSpan from={row.from} to={row.to} />
|
||||
{/if}
|
||||
|
||||
@@ -3,8 +3,14 @@ import EventPill from './EventPill.svelte';
|
||||
import WorldBand from './WorldBand.svelte';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import LetterBucket from './LetterBucket.svelte';
|
||||
import { isDense } from './timelineDensity';
|
||||
import { entryKey } from './entryKey';
|
||||
import {
|
||||
bucketLetters,
|
||||
type GroupingMode,
|
||||
type LetterBucket as LetterBucketModel
|
||||
} from './timelineGrouping';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||
@@ -15,19 +21,48 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
* 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).
|
||||
*
|
||||
* In Ereignis/Thema mode (#827) the event pills/world-bands render identically
|
||||
* (REQ-001); only the loose letters re-bundle into per-year buckets below them
|
||||
* (REQ-002/003/004). Datum mode is the original individual-card / density-strip
|
||||
* path, untouched.
|
||||
*/
|
||||
let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props();
|
||||
let {
|
||||
year,
|
||||
canWrite = false,
|
||||
groupingMode = 'date',
|
||||
eventLookup = new Map<string, string>()
|
||||
}: {
|
||||
year: TimelineYearDTO;
|
||||
canWrite?: boolean;
|
||||
groupingMode?: GroupingMode;
|
||||
eventLookup?: Map<string, string>;
|
||||
} = $props();
|
||||
|
||||
type Row =
|
||||
| { t: 'event'; entry: TimelineEntryDTO }
|
||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
||||
| { t: 'strip' };
|
||||
| { t: 'strip' }
|
||||
| { t: 'bucket'; bucket: LetterBucketModel };
|
||||
|
||||
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
|
||||
const dense = $derived(isDense(letters.length));
|
||||
const grouped = $derived(groupingMode !== 'date');
|
||||
const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
if (grouped) {
|
||||
// Events stay on the axis, identical to Datum mode (REQ-001); only the loose
|
||||
// letters re-bundle into per-year buckets below them (REQ-003/004).
|
||||
for (const entry of year.entries) {
|
||||
if (entry.kind === 'EVENT') out.push({ t: 'event', entry });
|
||||
}
|
||||
for (const bucket of bucketLetters(letters, bucketMode, eventLookup)) {
|
||||
out.push({ t: 'bucket', bucket });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let stripInserted = false;
|
||||
let letterIndex = 0;
|
||||
for (const entry of year.entries) {
|
||||
@@ -43,6 +78,12 @@ const rows = $derived.by<Row[]>(() => {
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function rowKey(row: Row): string {
|
||||
if (row.t === 'strip') return `strip-${year.year}`;
|
||||
if (row.t === 'bucket') return row.bucket.key;
|
||||
return entryKey(row.entry);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-2">
|
||||
@@ -56,7 +97,7 @@ const rows = $derived.by<Row[]>(() => {
|
||||
</h2>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
|
||||
{#each rows as row (rowKey(row))}
|
||||
{#if row.t === 'event'}
|
||||
{#if row.entry.type === 'HISTORICAL'}
|
||||
<WorldBand entry={row.entry} canWrite={canWrite} />
|
||||
@@ -68,6 +109,8 @@ const rows = $derived.by<Row[]>(() => {
|
||||
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||
<LetterCard entry={row.entry} />
|
||||
</div>
|
||||
{:else if row.t === 'bucket'}
|
||||
<LetterBucket bucket={row.bucket} mode={bucketMode} />
|
||||
{:else}
|
||||
<YearLetterStrip letters={letters} year={year.year} />
|
||||
{/if}
|
||||
|
||||
@@ -165,3 +165,60 @@ describe('YearBand', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('YearBand — grouping modes (#827)', () => {
|
||||
it('keeps individual letter cards and no buckets in Datum mode (default)', () => {
|
||||
render(YearBand, { year: makeYear(1915, manyLetters(1915, 3)) });
|
||||
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
|
||||
expect(document.querySelectorAll('a')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('clusters loose letters under their linked event in Ereignis mode (REQ-002/003)', () => {
|
||||
const a = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1915-03-01' });
|
||||
const b = makeEntry({ documentId: 'b', linkedEventId: 'e1', eventDate: '1915-04-01' });
|
||||
render(YearBand, {
|
||||
year: makeYear(1915, [a, b]),
|
||||
groupingMode: 'event',
|
||||
eventLookup: new Map([['e1', 'Briefe von der Front']])
|
||||
});
|
||||
expect(document.querySelectorAll('[data-testid="letter-bucket"]')).toHaveLength(1);
|
||||
expect(document.body.textContent).toContain('Briefe von der Front');
|
||||
// no alternating individual letter rows in grouped mode
|
||||
expect(document.querySelector('.letter-row')).toBeNull();
|
||||
});
|
||||
|
||||
it('still renders the event world-band in Ereignis mode (REQ-001)', () => {
|
||||
const band = makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'HISTORICAL',
|
||||
precision: 'RANGE',
|
||||
eventDate: '1914-01-01',
|
||||
eventDateEnd: '1918-12-31',
|
||||
title: 'Erster Weltkrieg',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1914-05-01' });
|
||||
render(YearBand, {
|
||||
year: makeYear(1914, [band, letter]),
|
||||
groupingMode: 'event',
|
||||
eventLookup: new Map([['e1', 'Front']])
|
||||
});
|
||||
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('buckets loose letters under their root tag in Thema mode (REQ-004)', () => {
|
||||
const a = makeEntry({
|
||||
documentId: 'a',
|
||||
rootTagId: 't1',
|
||||
rootTagName: 'Krieg',
|
||||
rootTagColor: 'sienna',
|
||||
eventDate: '1915-03-01'
|
||||
});
|
||||
render(YearBand, { year: makeYear(1915, [a]), groupingMode: 'thema', eventLookup: new Map() });
|
||||
const chip = document.querySelector('[data-testid="bucket-header-chip"]');
|
||||
expect(chip?.textContent).toContain('Krieg');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user