From 2421265e262553d9350fde5771965ffb48706f9c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 20:41:52 +0200 Subject: [PATCH] feat(timeline): cluster event letters inline in YearBand, loose letters stay chronological MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A curated event with same-year linked letters renders as one EventCluster card (no separate pill); a cluster whose event lives in another band renders as a cross-year text-header card. Letterless/derived/world events stay plain pills/world-bands. Loose letters keep the alternating left/right layout and fold into one density strip past 12 — and the layout + strip count ONLY the loose letters, so a clustered letter never re-appears loose. Refs #850 --- frontend/src/lib/timeline/YearBand.svelte | 97 +++++++++++++++--- .../src/lib/timeline/YearBand.svelte.spec.ts | 99 +++++++++++++++++++ frontend/src/lib/timeline/eventClustering.ts | 4 +- 3 files changed, 183 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index fafa0b4c..ef4579d2 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -3,8 +3,10 @@ import EventPill from './EventPill.svelte'; import WorldBand from './WorldBand.svelte'; import LetterCard from './LetterCard.svelte'; import YearLetterStrip from './YearLetterStrip.svelte'; +import EventCluster from './EventCluster.svelte'; import { isDense } from './timelineDensity'; import { entryKey } from './entryKey'; +import { splitYearLetters, type EventCluster as EventClusterModel } from './eventClustering'; import type { components } from '$lib/generated/api'; type TimelineYearDTO = components['schemas']['TimelineYearDTO']; @@ -12,37 +14,95 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; /** * One year of the timeline: a
with a sticky

(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). + * render in DTO order as pills/world-bands; entries are never re-sorted (REQ-003). + * + * A curated event with letters linked to it (#850) becomes a contained event card: + * the event IS the card header and its linked letters sit inside (no separate pill — + * REQ-002). A curated event with letters in another year band renders here as a + * cross-year text-header card (REQ-004). An event with no linked letters stays a + * plain pill/world-band (REQ-005). + * + * Every other letter (no linkedEventId, or linking to an event the #780 layer filter + * removed) stays loose: alternating left/right while the band holds ≤ 12 such loose + * letters (REQ-006), folding into a single month-density strip above that (REQ-007). + * The loose-letter layout and the strip count ONLY these loose letters — clustered + * letters never re-appear loose (REQ-007). */ -let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props(); +let { + year, + canWrite = false, + eventLookup +}: { + year: TimelineYearDTO; + canWrite?: boolean; + eventLookup?: Map; +} = $props(); type Row = | { t: 'event'; entry: TimelineEntryDTO } + | { t: 'eventcard'; event?: TimelineEntryDTO; cluster: EventClusterModel } | { 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)); +// Split this band's letters into event clusters and the loose remainder once; the loose +// list alone drives the alternating layout and the density strip (REQ-007). +const split = $derived( + splitYearLetters( + year.entries.filter((e) => e.kind === 'LETTER'), + eventLookup + ) +); +const loose = $derived(split.loose); +const dense = $derived(isDense(loose.length)); const rows = $derived.by(() => { const out: Row[] = []; + const { clusters } = split; + const consumed: string[] = []; 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; + // A curated event whose letters live in THIS band becomes the contained card's + // header — its title reads once, no separate pill (REQ-002). Otherwise it stays a + // plain pill/world-band (REQ-005). + const cluster = entry.eventId ? clusters.find((c) => c.eventId === entry.eventId) : undefined; + if (cluster) { + out.push({ t: 'eventcard', event: entry, cluster }); + consumed.push(cluster.eventId); + } else { + out.push({ t: 'event', entry }); + } + } else if (loose.includes(entry)) { + // A loose letter: alternate while sparse, or fold the whole loose set into one + // density strip (inserted once, at the first loose letter) when dense. + if (!dense) { + out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' }); + letterIndex += 1; + } else if (!stripInserted) { + out.push({ t: 'strip' }); + stripInserted = true; + } + } + // a clustered letter is rendered by its event card (or the cross-year pass) — skip here. + } + + // Cross-year clusters: a cluster whose event is NOT a same-year EVENT entry renders as a + // text-header card (no pill, no edit link) holding this year's linked letters (REQ-004). + for (const cluster of clusters) { + if (!consumed.includes(cluster.eventId)) { + out.push({ t: 'eventcard', cluster }); } } return out; }); + +function rowKey(row: Row): string { + if (row.t === 'strip') return `strip-${year.year}`; + if (row.t === 'eventcard') return `evcard:${row.cluster.eventId}`; + return entryKey(row.entry); +}
@@ -56,20 +116,27 @@ const rows = $derived.by(() => {

- {#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'} {:else} {/if} + {:else if row.t === 'eventcard'} + {:else if row.t === 'letter'}
{:else} - + {/if} {/each}
diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 45845080..2b280f3f 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -165,3 +165,102 @@ describe('YearBand', () => { } }); }); + +describe('YearBand — inline event clustering (#850)', () => { + const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + + function curatedEvent(overrides = {}) { + return makeEntry({ + kind: 'EVENT', + derived: false, + type: 'PERSONAL', + eventId: EV_ID, + eventDate: '1916-07-06', + precision: 'DAY', + title: 'Ein gewaltiger Stadtbrand', + senderName: '', + receiverName: '', + documentId: undefined, + ...overrides + }); + } + + function linkedLetters(year: number, count: number, eventId = EV_ID) { + return Array.from({ length: count }, (_, i) => + makeEntry({ + eventDate: `${year}-05-10`, + documentId: `linked-${i}`, + title: `Brief ${i}`, + linkedEventId: eventId + }) + ); + } + + const lookup = new Map([[EV_ID, 'Ein gewaltiger Stadtbrand']]); + + it('renders a curated event with a same-year linked letter as one event-card, title once, no separate pill (REQ-002)', () => { + render(YearBand, { + year: makeYear(1916, [curatedEvent(), ...linkedLetters(1916, 1)]), + eventLookup: lookup + }); + expect(document.querySelectorAll('[data-testid="event-card"]')).toHaveLength(1); + const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length; + expect(titles).toBe(1); + // the letter is inside the card, not a loose .letter-row + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + expect(document.querySelector('.letter-row')).toBeNull(); + // no plain EventPill for it (the pill is the only floating .rounded-full wrapper) + expect(document.querySelector('.justify-center .rounded-full.border-brand-mint')).toBeNull(); + }); + + it('renders a curated event with NO linked letters as a plain EventPill, no card (REQ-005)', () => { + render(YearBand, { + year: makeYear(1916, [curatedEvent()]), + eventLookup: lookup + }); + expect(document.querySelector('[data-testid="event-card"]')).toBeNull(); + // the curated EventPill is the bordered floating rounded-full wrapper + expect( + document.querySelector('.justify-center .rounded-full.border-brand-mint') + ).not.toBeNull(); + expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand'); + }); + + it('renders loose (unlinked) letters in a sparse year as alternating .letter-row, no card (REQ-006)', () => { + const loose = manyLetters(1916, 3); // no linkedEventId + render(YearBand, { year: makeYear(1916, loose), eventLookup: lookup }); + expect(document.querySelectorAll('.letter-row')).toHaveLength(3); + expect(document.querySelector('[data-testid="event-card"]')).toBeNull(); + }); + + it('counts only loose letters in the density strip; event letters stay in the card (REQ-006/007)', () => { + // 15 loose letters fold into one strip; a 3-letter event card shows its 3. + const loose = manyLetters(1916, 15); + const year = makeYear(1916, [curatedEvent(), ...linkedLetters(1916, 3), ...loose]); + render(YearBand, { year, eventLookup: lookup }); + // the event card holds 3 letters + expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(3); + // the loose letters fold into exactly one density strip + const strips = document.querySelectorAll('[data-testid="strip-expand"]'); + expect(strips).toHaveLength(1); + // the strip card's count text is 15 (the loose letters), not 18 (REQ-006/007) + const stripCard = strips[0].closest('.max-w-md') as HTMLElement; + expect(stripCard.textContent).toContain('15'); + expect(stripCard.textContent).not.toContain('18'); + }); + + it('renders a cross-year cluster (event not in this band) as a ✉ text-header card holding it (REQ-004)', () => { + // The event id is in eventLookup but no matching EVENT entry sits in this band. + render(YearBand, { + year: makeYear(1917, linkedLetters(1917, 2)), + eventLookup: lookup + }); + const card = document.querySelector('[data-testid="event-card"]'); + expect(card).not.toBeNull(); + expect(document.body.textContent).toContain('✉'); + expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand'); + // cross-year card carries no edit link and no pill + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + expect(document.querySelector('.justify-center .rounded-full.border-brand-mint')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/timeline/eventClustering.ts b/frontend/src/lib/timeline/eventClustering.ts index 379bcbf2..45e28c6f 100644 --- a/frontend/src/lib/timeline/eventClustering.ts +++ b/frontend/src/lib/timeline/eventClustering.ts @@ -48,7 +48,7 @@ export function buildEventLookup(timeline: TimelineDTO): Map { */ export function splitYearLetters( letters: TimelineEntryDTO[], - eventLookup: Map + eventLookup?: Map ): SplitLetters { const byEvent = new Map(); const clusters: EventCluster[] = []; @@ -56,7 +56,7 @@ export function splitYearLetters( for (const letter of letters) { const eventId = letter.linkedEventId; - const title = eventId != null ? eventLookup.get(eventId) : undefined; + const title = eventId != null ? eventLookup?.get(eventId) : undefined; if (eventId != null && title !== undefined) { let cluster = byEvent.get(eventId); if (!cluster) {