fix(timeline): interleave cross-year cards at their earliest letter
Cross-year clusters were appended after every event and loose letter, so a band with a loose November letter plus February letters linked to another year's event rendered the February ✉ card BELOW the November letter — earlier-dated content sitting visually below later-dated content, breaking the strict-time reading the band guarantees. A cross-year cluster (no same-year EVENT anchor in this band) now emits its card at the position of its earliest linked letter, in the band's chronological order. Closes the spec gap pinned as REQ-015. Fixes review finding #1. Refs #850 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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<Record<string, true>>(() => {
|
||||
const ids: Record<string, true> = {};
|
||||
for (const entry of year.entries) {
|
||||
if (entry.kind === 'EVENT' && entry.eventId) ids[entry.eventId] = true;
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
const { clusters } = split;
|
||||
const consumed: Record<string, true> = {};
|
||||
const emitted: Record<string, true> = {};
|
||||
let stripInserted = false;
|
||||
let letterIndex = 0;
|
||||
|
||||
@@ -74,14 +83,15 @@ const rows = $derived.by<Row[]>(() => {
|
||||
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<Row[]>(() => {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user