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:
Marcel
2026-06-15 20:41:52 +02:00
committed by marcel
parent 05ad2ac3fc
commit 2421265e26
3 changed files with 183 additions and 17 deletions

View File

@@ -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,37 +14,95 @@ 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') {
out.push({ t: 'event', entry });
} else if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
// 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 (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) {
out.push({ t: 'strip' });
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>