Cluster event letters inline in the chronological /zeitstrahl (no grouping toggle) (#851)
Some checks failed
CI / Unit & Component Tests (push) Successful in 7m35s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 12m42s
CI / fail2ban Regex (push) Successful in 1m50s
CI / Semgrep Security Scan (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Successful in 1m18s
Some checks failed
CI / Unit & Component Tests (push) Successful in 7m35s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 12m42s
CI / fail2ban Regex (push) Successful in 1m50s
CI / Semgrep Security Scan (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Successful in 1m18s
Closes #850 ## Summary On `/zeitstrahl`, a curated event that has letters linked to it now renders as a contained event card — the event is the card header (accent glyph, title, `{date} · {kuratiert|abgeleitet}` subtitle, count, and a curator edit link), with its linked letters listed inside (first 5, then a keyboard-operable show-more/less toggle). Letters in a year *other* than the event's band get a lighter cross-year `✉ title` card. Every other letter stays a plain, alternating, density-folding chronological letter. There is **no grouping control** — clustering is automatic and always on. The meta-line drops its `Gruppierung: Datum` segment. This supersedes #827: it keeps that branch's event-card clustering and the computed `linkedEventId`, and drops the toggle, the Thema mode, and the "Weitere Briefe" drawer. ## What changed **Backend** - `TimelineEntryDTO` gains a nullable `linkedEventId` (UUID; not `@Schema(REQUIRED)`). - `TimelineService.resolveLetterEventLinks` resolves each letter's curated event in one batched pass over the events it already loads — no per-letter query, no new column, no Flyway migration. - Regenerated the single `linkedEventId?` field in `api.ts`. **Frontend** - New `eventClustering.ts` (`buildEventLookup`, `splitYearLetters`, `CLUSTER_PREVIEW=5`) — filter-then-cluster: a letter clusters only if its `linkedEventId` is set AND present in the lookup, otherwise it stays loose. - New `EventCluster.svelte` — the contained event card (same-year event header + edit link, or cross-year ✉ text header; first-5 + show-more). - `LetterCard.svelte` gains `compact` + `variant='event'` (the `.lcard.ev` in-card letter). - `YearBand.svelte` rebuilt to render event clusters inline; loose letters keep the alternating layout and density strip, and the strip counts **only** loose letters (no duplication). - `TimelineView.svelte` builds the event lookup once and threads it + `canWrite` to each band. - `+page.svelte` drops the grouping meta segment; the unused `timeline_grouping_date` key removed from de/en/es. - New `timeline_bucket_show_more`/`_less` keys in all locales. - REQ-010 `{@html}` grep gate over `lib/timeline/`. ## Tests (real runs) - Backend `TimelineServiceTest`: **30 passed** (incl. the 2 new `linkedEventId` tests); `DerivedEventsAssemblyTest`: 17 passed; backend main sources compile. - Frontend client sweep (`LetterCard`, `EventCluster`, `YearBand`, `TimelineView`, `zeitstrahl/page`): **81 passed** (5 files). - Frontend server sweep (`eventClustering`, `messages`, `timeline-no-raw-html`): **18 passed** (3 files). - `svelte-check`: no new errors in the touched files (pre-existing baseline noise elsewhere unchanged). RTM: thirteen `REQ-001..013` rows added for #850 (feature `inline-event-clustering`), Status Done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Marcel <marcel@familienarchiv> Reviewed-on: #851
This commit was merged in pull request #851.
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,37 +14,113 @@ 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));
|
||||
// Clusters keyed by eventId (built once in splitYearLetters): row assembly looks a letter's
|
||||
// disposition up in O(1) — `byEvent.has(linkedEventId)` — instead of scanning the loose array
|
||||
// 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 emitted: Record<string, true> = {};
|
||||
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 ? byEvent.get(entry.eventId) : undefined;
|
||||
if (cluster) {
|
||||
out.push({ t: 'eventcard', event: entry, cluster });
|
||||
emitted[cluster.eventId] = true;
|
||||
} else {
|
||||
out.push({ t: 'event', entry });
|
||||
}
|
||||
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) {
|
||||
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
|
||||
letterIndex += 1;
|
||||
} else if (!stripInserted) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 +134,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>
|
||||
|
||||
Reference in New Issue
Block a user