fix(timeline): keep HISTORICAL events out of inline clustering

buildEventLookup keyed on `kind === 'EVENT' && eventId` with no type
check, so a HISTORICAL curated event with ≥1 linked letter entered the
lookup and rendered as a mint EventCluster card — silently downgrading
from the full-width WorldBand that #779 REQ-009 mandates ("world-bands
render exactly as before").

The lookup now excludes `type === 'HISTORICAL'`, so a world event always
keeps its WorldBand and its letters stay loose chronological. Closes the
spec gap pinned as REQ-014. Fixes review finding #2.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 22:06:01 +02:00
committed by marcel
parent d9e431cd12
commit 4b965e655e
4 changed files with 53 additions and 4 deletions

View File

@@ -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-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-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-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 | | 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 |

View File

@@ -371,4 +371,37 @@ describe('TimelineView', () => {
const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length; const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length;
expect(titles).toBe(1); 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');
});
}); });

View File

@@ -44,6 +44,18 @@ describe('eventClustering — buildEventLookup', () => {
}; };
expect(buildEventLookup(timeline).size).toBe(0); 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', () => { describe('eventClustering — splitYearLetters', () => {

View File

@@ -25,14 +25,18 @@ export interface SplitLetters {
* Maps each curated event present in the (already layer-filtered) timeline to its title. These * 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 * 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 * 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 * letter (filter-then-cluster, REQ-008). Curated PERSONAL events carry an `eventId`; derived
* and letters do not, so they never enter the lookup. * 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<string, string> { export function buildEventLookup(timeline: TimelineDTO): Map<string, string> {
const lookup = new Map<string, string>(); const lookup = new Map<string, string>();
const collect = (entries: TimelineEntryDTO[]) => { const collect = (entries: TimelineEntryDTO[]) => {
for (const entry of entries) { 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); for (const band of timeline.years) collect(band.entries);