diff --git a/.specify/rtm.md b/.specify/rtm.md index f37acb48..7d6b23eb 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -207,5 +207,5 @@ | REQ-011 | wrapping header keeps #842 add-event CTA + #780 filter trigger; meta-line drops the grouping segment | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`, `#drops the letters segment instead of showing "0 Briefe"` (no "Gruppierung") | Done | | REQ-012 | show-more/less labels are new Paraglide keys in de/en/es; unused #827 grouping/Thema keys removed | #850 | inline-event-clustering | `frontend/messages/{de,en,es}.json`, `frontend/src/lib/timeline/EventCluster.svelte` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales` (now asserts `timeline_bucket_show_more`/`_less`; `timeline_grouping_date` removed) | Done | | REQ-013 | `GET /api/timeline` failure → existing localized error state via `getErrorMessage(code)` (unchanged #779) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` (unchanged error path) | covered by #779 `zeitstrahl` error-state tests (regression — no change) | Done | -| REQ-014 | HISTORICAL curated event with ≥1 linked letter keeps its full-width WorldBand — never clusters into a card (preserves #779 REQ-009); its letters stay loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`buildEventLookup` excludes HISTORICAL) | `eventClustering.spec.ts#excludes a HISTORICAL event from the lookup`; `YearBand.svelte.spec.ts#renders a HISTORICAL event with a same-year linked letter as a WorldBand, letter loose` | Planned | +| REQ-014 | HISTORICAL curated event with ≥1 linked letter keeps its full-width WorldBand — never clusters into a card (preserves #779 REQ-009); its letters stay loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`buildEventLookup` excludes HISTORICAL) | `eventClustering.spec.ts#excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)`; `TimelineView.svelte.spec.ts#keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)` | Done | | REQ-015 | a cross-year ✉ card is placed at its earliest linked letter's chronological position in the band — never appended after later-dated loose letters | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#interleaves a cross-year card before a later-dated loose letter in the same band` | Planned | diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts index 47a73c8b..00e72e47 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -371,4 +371,37 @@ describe('TimelineView', () => { const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length; expect(titles).toBe(1); }); + + it('keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)', () => { + const evId = 'dddddddd-dddd-dddd-dddd-dddddddddddd'; + const world = makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + derived: false, + eventId: evId, + eventDate: '1916-07-01', + precision: 'DAY', + title: 'Schlacht an der Somme', + senderName: '', + receiverName: '', + documentId: undefined + }); + const letter = makeEntry({ + eventDate: '1916-05-10', + documentId: 'doc-world-linked', + title: 'Brief von der Front', + linkedEventId: evId + }); + render(TimelineView, { + timeline: makeTimelineDTO({ years: [makeYear(1916, [world, letter])] }) + }); + // the world event stays a full-width band — no contained event card + expect(document.querySelector('[data-testid="event-card"]')).toBeNull(); + expect(document.querySelector('a.lcard.ev')).toBeNull(); + // the linked letter renders loose on the spine, not inside a card + expect(document.querySelector('.letter-row')).not.toBeNull(); + // and the band keeps its WorldBand "· historisch" register + expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix()); + expect(document.body.textContent).toContain('Schlacht an der Somme'); + }); }); diff --git a/frontend/src/lib/timeline/eventClustering.spec.ts b/frontend/src/lib/timeline/eventClustering.spec.ts index eb90f07e..c0419c02 100644 --- a/frontend/src/lib/timeline/eventClustering.spec.ts +++ b/frontend/src/lib/timeline/eventClustering.spec.ts @@ -44,6 +44,18 @@ describe('eventClustering — buildEventLookup', () => { }; expect(buildEventLookup(timeline).size).toBe(0); }); + + it('excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)', () => { + const timeline: TimelineDTO = { + years: [ + { year: 1916, entries: [makeEvent({ eventId: EV_A, type: 'HISTORICAL', title: 'Somme' })] } + ], + undated: [] + }; + const lookup = buildEventLookup(timeline); + expect(lookup.has(EV_A)).toBe(false); + expect(lookup.size).toBe(0); + }); }); describe('eventClustering — splitYearLetters', () => { diff --git a/frontend/src/lib/timeline/eventClustering.ts b/frontend/src/lib/timeline/eventClustering.ts index 45e28c6f..3b2e7e62 100644 --- a/frontend/src/lib/timeline/eventClustering.ts +++ b/frontend/src/lib/timeline/eventClustering.ts @@ -25,14 +25,18 @@ export interface SplitLetters { * Maps each curated event present in the (already layer-filtered) timeline to its title. These * are the only events a letter may cluster under — a letter whose `linkedEventId` is absent here * links to an event the #780 layer filter removed, so it falls back to a loose chronological - * letter (filter-then-cluster, REQ-008). Curated events carry an `eventId`; derived life-events - * and letters do not, so they never enter the lookup. + * letter (filter-then-cluster, REQ-008). Curated PERSONAL events carry an `eventId`; derived + * life-events and letters do not, so they never enter the lookup. HISTORICAL events are excluded + * too: a world event always keeps its full-width WorldBand and never clusters, even with linked + * letters (REQ-014) — those letters stay loose. */ export function buildEventLookup(timeline: TimelineDTO): Map { const lookup = new Map(); const collect = (entries: TimelineEntryDTO[]) => { for (const entry of entries) { - if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? ''); + if (entry.kind === 'EVENT' && entry.eventId && entry.type !== 'HISTORICAL') { + lookup.set(entry.eventId, entry.title ?? ''); + } } }; for (const band of timeline.years) collect(band.entries);