feat(timeline): cluster event letters inline in YearBand, loose letters stay chronological
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
This commit is contained in:
@@ -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,28 +14,70 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* One year of the timeline: a <section> with a sticky <h2> (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<string, string>;
|
||||
} = $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<Row[]>(() => {
|
||||
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') {
|
||||
// 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 (!dense) {
|
||||
}
|
||||
} 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) {
|
||||
@@ -41,8 +85,24 @@ const rows = $derived.by<Row[]>(() => {
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-2">
|
||||
@@ -56,20 +116,27 @@ const rows = $derived.by<Row[]>(() => {
|
||||
</h2>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
{#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'}
|
||||
<WorldBand entry={row.entry} canWrite={canWrite} />
|
||||
{:else}
|
||||
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||
{/if}
|
||||
{:else if row.t === 'eventcard'}
|
||||
<EventCluster
|
||||
letters={row.cluster.letters}
|
||||
event={row.event}
|
||||
title={row.cluster.title}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
{:else if row.t === 'letter'}
|
||||
<div class="letter-row" data-side={row.side}>
|
||||
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||
<LetterCard entry={row.entry} />
|
||||
</div>
|
||||
{:else}
|
||||
<YearLetterStrip letters={letters} year={year.year} />
|
||||
<YearLetterStrip letters={loose} year={year.year} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export function buildEventLookup(timeline: TimelineDTO): Map<string, string> {
|
||||
*/
|
||||
export function splitYearLetters(
|
||||
letters: TimelineEntryDTO[],
|
||||
eventLookup: Map<string, string>
|
||||
eventLookup?: Map<string, string>
|
||||
): SplitLetters {
|
||||
const byEvent = new Map<string, EventCluster>();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user