diff --git a/.specify/rtm.md b/.specify/rtm.md index 7d6b23eb..08c560d3 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -208,4 +208,4 @@ | 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 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 (REQ-015)` | Done | diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index 2783d141..f1e62d0e 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -59,10 +59,19 @@ const dense = $derived(isDense(loose.length)); // per letter (was O(L²) on a dense band), and resolves an event's card with `byEvent.get`. const byEvent = $derived(split.byEvent); +// Event ids that have a same-year EVENT entry in THIS band: those clusters render as that +// event's header (at the EVENT position); every other cluster is cross-year (REQ-004/015). +const sameYearEventIds = $derived.by>(() => { + const ids: Record = {}; + for (const entry of year.entries) { + if (entry.kind === 'EVENT' && entry.eventId) ids[entry.eventId] = true; + } + return ids; +}); + const rows = $derived.by(() => { const out: Row[] = []; - const { clusters } = split; - const consumed: Record = {}; + const emitted: Record = {}; let stripInserted = false; let letterIndex = 0; @@ -74,14 +83,15 @@ const rows = $derived.by(() => { const cluster = entry.eventId ? byEvent.get(entry.eventId) : undefined; if (cluster) { out.push({ t: 'eventcard', event: entry, cluster }); - consumed[cluster.eventId] = true; + emitted[cluster.eventId] = true; } else { out.push({ t: 'event', entry }); } - } else if ( - entry.kind === 'LETTER' && - !(entry.linkedEventId && byEvent.has(entry.linkedEventId)) - ) { + continue; + } + + const cluster = entry.linkedEventId ? byEvent.get(entry.linkedEventId) : undefined; + if (!cluster) { // A loose letter (not clustered): alternate while sparse, or fold the whole loose set // into one density strip (inserted once, at the first loose letter) when dense. if (!dense) { @@ -91,17 +101,18 @@ const rows = $derived.by(() => { out.push({ t: 'strip' }); stripInserted = true; } + continue; + } + + // A clustered letter. A same-year cluster is emitted at its EVENT entry, so skip it here. + // A cross-year cluster has no EVENT anchor in this band — emit its ✉ card HERE, at the + // position of its earliest linked letter, so the band stays in strict time order (REQ-015). + if (!sameYearEventIds[cluster.eventId] && !emitted[cluster.eventId]) { + out.push({ t: 'eventcard', cluster }); + emitted[cluster.eventId] = 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[cluster.eventId]) { - out.push({ t: 'eventcard', cluster }); - } - } return out; }); diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 2b280f3f..9bada13a 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -263,4 +263,28 @@ describe('YearBand — inline event clustering (#850)', () => { expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); expect(document.querySelector('.justify-center .rounded-full.border-brand-mint')).toBeNull(); }); + + it('interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)', () => { + // Chronological band order (what the backend delivers): a February cross-year letter, then + // a November loose letter. The cross-year card must sit at its earliest letter's position — + // before the November loose letter — so the band still reads in strict time order. + const febLinked = makeEntry({ + eventDate: '1917-02-10', + documentId: 'feb-linked', + title: 'Feldpostbrief', + linkedEventId: EV_ID + }); + const novLoose = makeEntry({ + eventDate: '1917-11-20', + documentId: 'nov-loose', + title: 'Brief im November' + }); + render(YearBand, { year: makeYear(1917, [febLinked, novLoose]), eventLookup: lookup }); + const card = document.querySelector('[data-testid="event-card"]') as HTMLElement; + const looseLink = document.querySelector('a[href="/documents/nov-loose"]') as HTMLElement; + expect(card).not.toBeNull(); + expect(looseLink).not.toBeNull(); + // the cross-year card precedes the later-dated loose letter in DOM order + expect(card.compareDocumentPosition(looseLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); });