feat(timeline): thread event clustering through TimelineView; drop the grouping meta segment

TimelineView builds the event lookup once over the whole timeline and threads
it (plus canWrite) to every YearBand, so a curated event's letters cluster
under it inline. The /zeitstrahl meta-line drops its 'Gruppierung: Datum'
segment (toggle-free view, REQ-011); the now-unused timeline_grouping_date
key is removed from de/en/es and the messages parity guard, which now asserts
the new show-more/less keys.

Refs #850
This commit is contained in:
Marcel
2026-06-15 20:45:45 +02:00
committed by marcel
parent 2421265e26
commit 6834381cb9
8 changed files with 46 additions and 9 deletions

View File

@@ -1049,7 +1049,6 @@
"timeline_derived_birth": "Geburt", "timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod", "timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat", "timeline_derived_marriage": "Heirat",
"timeline_grouping_date": "Gruppierung: Datum",
"timeline_provenance_derived": "abgeleitet", "timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert", "timeline_provenance_curated": "kuratiert",
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen", "timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",

View File

@@ -1049,7 +1049,6 @@
"timeline_derived_birth": "Birth", "timeline_derived_birth": "Birth",
"timeline_derived_death": "Death", "timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage", "timeline_derived_marriage": "Marriage",
"timeline_grouping_date": "Grouping: Date",
"timeline_provenance_derived": "derived", "timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated", "timeline_provenance_curated": "curated",
"timeline_bucket_show_more": "+ {count} more letters", "timeline_bucket_show_more": "+ {count} more letters",

View File

@@ -1049,7 +1049,6 @@
"timeline_derived_birth": "Nacimiento", "timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento", "timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio", "timeline_derived_marriage": "Matrimonio",
"timeline_grouping_date": "Agrupación: Fecha",
"timeline_provenance_derived": "derivado", "timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado", "timeline_provenance_curated": "curado",
"timeline_bucket_show_more": "+ {count} cartas más", "timeline_bucket_show_more": "+ {count} cartas más",

View File

@@ -74,9 +74,10 @@ describe('message key parity', () => {
// every locale so no surface ever falls back to a missing translation. // every locale so no surface ever falls back to a missing translation.
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => { it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
const requiredKeys = [ const requiredKeys = [
'timeline_grouping_date',
'timeline_provenance_derived', 'timeline_provenance_derived',
'timeline_provenance_curated', 'timeline_provenance_curated',
'timeline_bucket_show_more',
'timeline_bucket_show_less',
'timeline_letter_glyph_label', 'timeline_letter_glyph_label',
'timeline_layer_historical_suffix', 'timeline_layer_historical_suffix',
'timeline_strip_density_caption', 'timeline_strip_density_caption',

View File

@@ -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 } from './eventClustering';
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,6 +19,11 @@ 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.
*
* The event lookup is built once over the whole (already layer-filtered) timeline
* and threaded to every band so a curated event's letters cluster under it inline
* (#850, REQ-002). The undated bucket stays plain (events as pills, letters as
* cards) — out of clustering scope.
*/ */
let { let {
timeline, timeline,
@@ -25,6 +31,8 @@ let {
canWrite = false canWrite = false
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props(); }: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
const eventLookup = $derived(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 };
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
@@ -54,7 +62,7 @@ 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} eventLookup={eventLookup} />
{:else} {:else}
<GapSpan from={row.from} to={row.to} /> <GapSpan from={row.from} to={row.to} />
{/if} {/if}

View File

@@ -341,4 +341,34 @@ describe('TimelineView', () => {
expect(hrefs).toContain('/zeitstrahl/events/wb/edit'); expect(hrefs).toContain('/zeitstrahl/events/wb/edit');
expect(hrefs).toContain('/zeitstrahl/events/wu/edit'); expect(hrefs).toContain('/zeitstrahl/events/wu/edit');
}); });
it('builds the event lookup and clusters a curated event + same-year linked letter into an event-card (#850)', () => {
const evId = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';
const event = makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: evId,
eventDate: '1916-07-06',
precision: 'DAY',
title: 'Ein gewaltiger Stadtbrand',
senderName: '',
receiverName: '',
documentId: undefined
});
const letter = makeEntry({
eventDate: '1916-05-10',
documentId: 'doc-linked',
title: 'Brief',
linkedEventId: evId
});
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1916, [event, letter])] })
});
expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull();
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
// the title reads once — the event is the card header, not also a loose pill
const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length;
expect(titles).toBe(1);
});
}); });

View File

@@ -60,7 +60,7 @@ const metaLine = $derived.by(() => {
: m.timeline_events_count({ count: meta.eventCount }) : m.timeline_events_count({ count: meta.eventCount })
); );
} }
segments.push(m.timeline_grouping_date()); // REQ-011: the toggle-free chronological view carries no grouping segment.
return segments.join(' · '); return segments.join(' · ');
}); });
</script> </script>

View File

@@ -43,7 +43,7 @@ describe('/zeitstrahl page', () => {
expect(canvas?.querySelector('ol')).not.toBeNull(); expect(canvas?.querySelector('ol')).not.toBeNull();
}); });
it('renders the meta sub-line with range, counts, and grouping (REQ-002)', () => { it('renders the meta sub-line with range and counts, no grouping segment (REQ-011)', () => {
const dto = makeTimelineDTO({ const dto = makeTimelineDTO({
years: [ years: [
makeYear(1909, [ makeYear(1909, [
@@ -59,7 +59,8 @@ describe('/zeitstrahl page', () => {
expect(sub?.textContent).toContain('19091924'); expect(sub?.textContent).toContain('19091924');
expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 })); expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 }));
expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 })); expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 }));
expect(sub?.textContent).toContain(m.timeline_grouping_date()); // REQ-011: the toggle-free view drops the grouping meta segment.
expect(sub?.textContent).not.toContain('Gruppierung');
}); });
it('omits the range segment when there are no year bands (REQ-002)', () => { it('omits the range segment when there are no year bands (REQ-002)', () => {
@@ -84,7 +85,7 @@ describe('/zeitstrahl page', () => {
const sub = document.querySelector('[data-testid="timeline-meta"]'); const sub = document.querySelector('[data-testid="timeline-meta"]');
expect(sub).not.toBeNull(); expect(sub).not.toBeNull();
expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 })); expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 }));
expect(sub?.textContent).toContain(m.timeline_grouping_date()); expect(sub?.textContent).not.toContain('Gruppierung');
}); });
it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => { it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => {