Files
familienarchiv/frontend/src/lib/timeline/TimelineView.svelte
marcel 49d8ab78b4
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
Cluster event letters inline in the chronological /zeitstrahl (no grouping toggle) (#851)
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
2026-06-16 14:38:09 +02:00

142 lines
4.9 KiB
Svelte

<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import YearBand from './YearBand.svelte';
import GapSpan from './GapSpan.svelte';
import LetterCard from './LetterCard.svelte';
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import { entryKey } from './entryKey';
import { buildEventLookup } from './eventClustering';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
/**
* Orchestrates the global timeline (REQ-001/003). Renders the year bands the DTO
* delivers in order — never re-sorting — interleaving a folded GapSpan for each
* interior run of empty years (REQ-015), then the undated bucket (REQ-016). An
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
* for the per-person rail (issue #10) and is undefined here; it is not passed to
* leaf cards (REQ-025). Owns no <main> — the layout does.
*
* The event lookup is built once over the whole (already layer-filtered) timeline
* and threaded to every band so a curated event's letters cluster under it inline
* (#850, REQ-002). The undated bucket stays plain (events as pills, letters as
* cards) — out of clustering scope.
*/
let {
timeline,
personId = undefined,
canWrite = false
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
const eventLookup = $derived(buildEventLookup(timeline));
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
const years = timeline.years;
for (let i = 0; i < years.length; i++) {
if (i > 0) {
const prev = years[i - 1].year;
const cur = years[i].year;
if (cur - prev > 1) out.push({ t: 'gap', from: prev + 1, to: cur - 1 });
}
out.push({ t: 'band', year: years[i] });
}
return out;
});
const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length === 0);
</script>
{#if isEmpty}
<p class="py-12 text-center font-serif text-base text-ink-2">{m.timeline_empty_state()}</p>
{:else}
<!-- personId is a declared seam for the per-person Lebensweg rail (issue #10);
undefined in the global view, surfaced only on the root, never passed to
leaf cards (REQ-025). -->
<ol class="timeline-axis relative mx-auto max-w-3xl" data-person-id={personId}>
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
<li>
{#if row.t === 'band'}
<YearBand year={row.year} canWrite={canWrite} eventLookup={eventLookup} />
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}
</li>
{/each}
</ol>
{#if timeline.undated.length > 0}
<section
data-testid="undated-section"
class="mx-auto mt-8 max-w-3xl rounded-sm border border-dashed border-line bg-surface p-4"
>
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">
{m.timeline_undated_section()} · {timeline.undated.length}
</h2>
<ul class="space-y-2">
<!-- The undated bucket is filtered from ALL entries, so it can hold
events as well as letters. Dispatch on kind/type exactly like
YearBand — an event rendered as a LetterCard would link to
/documents/undefined and read "Unknown → Unknown" (REQ-007/008/009). -->
{#each timeline.undated as entry (entryKey(entry))}
<li>
{#if entry.kind === 'EVENT'}
{#if entry.type === 'HISTORICAL'}
<WorldBand entry={entry} canWrite={canWrite} />
{:else}
<EventPill entry={entry} canWrite={canWrite} />
{/if}
{:else}
<LetterCard entry={entry} />
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<style>
/* Establish a stacking context so the spine (z-index: -1) sits behind the
in-flow cards/pills/strips but still in front of the canvas — the line is
always background; the badges, dots and markers ride on top of it.
--spine-x is the single source of truth for the spine's X position; the
year-node and connector dots in YearBand consume it via inheritance so the
markers can never desync from the line. */
.timeline-axis {
--spine-x: 0.5rem;
position: relative;
z-index: 0;
}
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
centered spine the bands' alternating cards sit on either side of. The
spine is decorative — the chronology lives in the <ol> DOM order. */
.timeline-axis::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: var(--spine-x);
width: 2px;
z-index: -1;
/* Three-stop life-thread: mint → navy → slate. Slate lives only as
--c-tag-slate (there is no --palette-slate). REQ-006/013. */
background: linear-gradient(var(--palette-mint), var(--palette-navy), var(--c-tag-slate));
}
@media (min-width: 1024px) {
.timeline-axis {
--spine-x: 50%;
}
.timeline-axis::before {
transform: translateX(-50%);
}
}
</style>