Files
familienarchiv/frontend/src/lib/timeline/LetterCard.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

87 lines
3.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { timelineDateLabel } from './dateLabel';
import GlyphLabel from './GlyphLabel.svelte';
import TagChip from './TagChip.svelte';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A single archive letter on the timeline: sender → receiver, title, and a
* precision-aware date chip, linking to the document. Names/titles are
* OCR/import-derived — rendered via default `{...}` escaping with
* `whitespace-pre-line` for line breaks (REQ-010); never the raw-HTML directive.
*
* Inside an event cluster the card sits in the contained event card and renders as
* the `.lcard.ev` `compact` variant (#850, REQ-002): tighter row, and the redundant
* date chip is dropped when the title already embeds the date. The per-letter tag
* chip can be suppressed via `suppressTagChip` for callers that already convey it.
*/
let {
entry,
variant = 'plain',
suppressTagChip = false,
compact = false
}: {
entry: TimelineEntryDTO;
variant?: 'plain' | 'event';
suppressTagChip?: boolean;
compact?: boolean;
} = $props();
const isEventVariant = $derived(variant === 'event');
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Inside an event card the band frames the time, so a compact in-card letter drops the
// redundant date chip — but ONLY when the (free-form OCR) title actually embeds the formatted
// date, e.g. "H-0023 6. Juli 1916". A title without the date keeps its chip, so a letter like
// "Brief an Mutter" never loses its month/day (the band frames only the year) — #850, finding #4.
const titleEmbedsDate = $derived(!!dateLabel && !!entry.title && entry.title.includes(dateLabel));
const showDate = $derived(!compact || !titleEmbedsDate);
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
);
</script>
<!-- Box layout inline (not just utility classes) so the 44px touch target holds
even before the stylesheet loads — an <a> is inline by default and would
ignore min-height otherwise. WCAG 2.5.5 (REQ-020). -->
<a
href="/documents/{entry.documentId}"
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
class="lcard rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
class:py-2={!compact}
class:py-1={compact}
class:ev={isEventVariant}
class:compact={compact}
>
{#if entry.title}
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
interpolated into the escaped user title; the title keeps its own
pre-line span for multi-line OCR text (REQ-008/016/021). -->
<span
class="font-serif font-bold break-words text-ink"
class:text-sm={!compact}
class:text-xs={compact}
>
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
<span class="whitespace-pre-line">{entry.title}</span>
</span>
{/if}
<span class="font-sans text-xs break-words text-ink-3" class:mt-0.5={!compact}>
<span class="font-serif whitespace-pre-line">{sender}</span>
<span aria-hidden="true"></span>
<span class="font-serif whitespace-pre-line">{receiver}</span>
{#if dateLabel && showDate}
<span data-testid="letter-date"> · {dateLabel}</span>
{/if}
</span>
{#if entry.rootTagName && !suppressTagChip}
<!-- The primary root-tag chip sits on its own line beneath the meta line
(#835 §3); absent when the letter has no tag (REQ-006), and suppressed when
the caller already conveys the topic (suppressTagChip). -->
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
{/if}
</a>