From 6834381cb9b601d67ece3ea48ac246d76d1a5443 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 20:45:45 +0200 Subject: [PATCH] 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 --- frontend/messages/de.json | 1 - frontend/messages/en.json | 1 - frontend/messages/es.json | 1 - frontend/src/lib/messages.spec.ts | 3 +- frontend/src/lib/timeline/TimelineView.svelte | 10 ++++++- .../lib/timeline/TimelineView.svelte.spec.ts | 30 +++++++++++++++++++ frontend/src/routes/zeitstrahl/+page.svelte | 2 +- .../src/routes/zeitstrahl/page.svelte.spec.ts | 7 +++-- 8 files changed, 46 insertions(+), 9 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index ffabb217..e5929a88 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1049,7 +1049,6 @@ "timeline_derived_birth": "Geburt", "timeline_derived_death": "Tod", "timeline_derived_marriage": "Heirat", - "timeline_grouping_date": "Gruppierung: Datum", "timeline_provenance_derived": "abgeleitet", "timeline_provenance_curated": "kuratiert", "timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 75bd5dd1..2388aae4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1049,7 +1049,6 @@ "timeline_derived_birth": "Birth", "timeline_derived_death": "Death", "timeline_derived_marriage": "Marriage", - "timeline_grouping_date": "Grouping: Date", "timeline_provenance_derived": "derived", "timeline_provenance_curated": "curated", "timeline_bucket_show_more": "+ {count} more letters", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3d41e31a..5f99f5b6 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1049,7 +1049,6 @@ "timeline_derived_birth": "Nacimiento", "timeline_derived_death": "Fallecimiento", "timeline_derived_marriage": "Matrimonio", - "timeline_grouping_date": "Agrupación: Fecha", "timeline_provenance_derived": "derivado", "timeline_provenance_curated": "curado", "timeline_bucket_show_more": "+ {count} cartas más", diff --git a/frontend/src/lib/messages.spec.ts b/frontend/src/lib/messages.spec.ts index 155ae1a3..ecfeb9dd 100644 --- a/frontend/src/lib/messages.spec.ts +++ b/frontend/src/lib/messages.spec.ts @@ -74,9 +74,10 @@ describe('message key parity', () => { // 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)', () => { const requiredKeys = [ - 'timeline_grouping_date', 'timeline_provenance_derived', 'timeline_provenance_curated', + 'timeline_bucket_show_more', + 'timeline_bucket_show_less', 'timeline_letter_glyph_label', 'timeline_layer_historical_suffix', 'timeline_strip_density_caption', diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte index c3008e90..bee97daf 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -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 } from './eventClustering'; import type { components } from '$lib/generated/api'; 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 * for the per-person rail (issue #10) and is undefined here; it is not passed to * leaf cards (REQ-025). Owns no
— 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 { timeline, @@ -25,6 +31,8 @@ let { canWrite = false }: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props(); +const eventLookup = $derived(buildEventLookup(timeline)); + type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number }; const rows = $derived.by(() => { @@ -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}`)}
  • {#if row.t === 'band'} - + {:else} {/if} diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts index 6e5b42c0..47a73c8b 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -341,4 +341,34 @@ describe('TimelineView', () => { expect(hrefs).toContain('/zeitstrahl/events/wb/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); + }); }); diff --git a/frontend/src/routes/zeitstrahl/+page.svelte b/frontend/src/routes/zeitstrahl/+page.svelte index 759b3340..efd65fbe 100644 --- a/frontend/src/routes/zeitstrahl/+page.svelte +++ b/frontend/src/routes/zeitstrahl/+page.svelte @@ -60,7 +60,7 @@ const metaLine = $derived.by(() => { : 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(' · '); }); diff --git a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts index 3f8a8d7b..3a900329 100644 --- a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts +++ b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts @@ -43,7 +43,7 @@ describe('/zeitstrahl page', () => { 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({ years: [ makeYear(1909, [ @@ -59,7 +59,8 @@ describe('/zeitstrahl page', () => { expect(sub?.textContent).toContain('1909–1924'); 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_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)', () => { @@ -84,7 +85,7 @@ describe('/zeitstrahl page', () => { const sub = document.querySelector('[data-testid="timeline-meta"]'); expect(sub).not.toBeNull(); 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)', () => {