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 EventPill from './EventPill.svelte';
|
||||||
import WorldBand from './WorldBand.svelte';
|
import WorldBand from './WorldBand.svelte';
|
||||||
import { entryKey } from './entryKey';
|
import { entryKey } from './entryKey';
|
||||||
|
import { buildEventLookup, type GroupingMode } from './timelineGrouping';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type TimelineDTO = components['schemas']['TimelineDTO'];
|
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
|
* 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
|
* 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.
|
* 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 {
|
let {
|
||||||
timeline,
|
timeline,
|
||||||
personId = undefined,
|
personId = undefined,
|
||||||
canWrite = false
|
canWrite = false,
|
||||||
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
|
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 };
|
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}`)}
|
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
||||||
<li>
|
<li>
|
||||||
{#if row.t === 'band'}
|
{#if row.t === 'band'}
|
||||||
<YearBand year={row.year} canWrite={canWrite} />
|
<YearBand
|
||||||
|
year={row.year}
|
||||||
|
canWrite={canWrite}
|
||||||
|
groupingMode={groupingMode}
|
||||||
|
eventLookup={eventLookup}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<GapSpan from={row.from} to={row.to} />
|
<GapSpan from={row.from} to={row.to} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import EventPill from './EventPill.svelte';
|
|||||||
import WorldBand from './WorldBand.svelte';
|
import WorldBand from './WorldBand.svelte';
|
||||||
import LetterCard from './LetterCard.svelte';
|
import LetterCard from './LetterCard.svelte';
|
||||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||||
|
import LetterBucket from './LetterBucket.svelte';
|
||||||
import { isDense } from './timelineDensity';
|
import { isDense } from './timelineDensity';
|
||||||
import { entryKey } from './entryKey';
|
import { entryKey } from './entryKey';
|
||||||
|
import {
|
||||||
|
bucketLetters,
|
||||||
|
type GroupingMode,
|
||||||
|
type LetterBucket as LetterBucketModel
|
||||||
|
} from './timelineGrouping';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
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
|
* 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
|
* 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).
|
* (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 =
|
type Row =
|
||||||
| { t: 'event'; entry: TimelineEntryDTO }
|
| { t: 'event'; entry: TimelineEntryDTO }
|
||||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
| { 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 letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
|
||||||
const dense = $derived(isDense(letters.length));
|
const dense = $derived(isDense(letters.length));
|
||||||
|
const grouped = $derived(groupingMode !== 'date');
|
||||||
|
const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
|
||||||
|
|
||||||
const rows = $derived.by<Row[]>(() => {
|
const rows = $derived.by<Row[]>(() => {
|
||||||
const out: 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 stripInserted = false;
|
||||||
let letterIndex = 0;
|
let letterIndex = 0;
|
||||||
for (const entry of year.entries) {
|
for (const entry of year.entries) {
|
||||||
@@ -43,6 +78,12 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
}
|
}
|
||||||
return out;
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="py-2">
|
<section class="py-2">
|
||||||
@@ -56,7 +97,7 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="mt-3 space-y-3">
|
<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.t === 'event'}
|
||||||
{#if row.entry.type === 'HISTORICAL'}
|
{#if row.entry.type === 'HISTORICAL'}
|
||||||
<WorldBand entry={row.entry} canWrite={canWrite} />
|
<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>
|
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||||
<LetterCard entry={row.entry} />
|
<LetterCard entry={row.entry} />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if row.t === 'bucket'}
|
||||||
|
<LetterBucket bucket={row.bucket} mode={bucketMode} />
|
||||||
{:else}
|
{:else}
|
||||||
<YearLetterStrip letters={letters} year={year.year} />
|
<YearLetterStrip letters={letters} year={year.year} />
|
||||||
{/if}
|
{/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