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
119 lines
4.6 KiB
Svelte
119 lines
4.6 KiB
Svelte
<script lang="ts">
|
||
import * as m from '$lib/paraglide/messages.js';
|
||
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
||
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
|
||
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
||
import { filterTimeline } from '$lib/timeline/timelineFilter';
|
||
import type { PageData } from './$types';
|
||
|
||
let { data }: { data: PageData } = $props();
|
||
|
||
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
||
|
||
// Layer-filter state (#780). Layer hiding is client-side only — the whole
|
||
// timeline is loaded once by #779's SSR load and we derive a filtered view of
|
||
// it here; there is no goto, no URL param, and no extra fetch.
|
||
let personalOn = $state(true);
|
||
let historicalOn = $state(true);
|
||
let lettersOn = $state(true);
|
||
|
||
const filteredTimeline = $derived(
|
||
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
|
||
);
|
||
const filteredEmpty = $derived(
|
||
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
|
||
);
|
||
|
||
// Meta-line figures track the *filtered* view, so the header counts always
|
||
// match what is actually on screen once layers are toggled off (#780 — this
|
||
// closes the prior D1 limitation, where the counts stayed on the full timeline).
|
||
const meta = $derived(timelineMeta(filteredTimeline));
|
||
|
||
function resetFilters() {
|
||
personalOn = true;
|
||
historicalOn = true;
|
||
lettersOn = true;
|
||
}
|
||
|
||
// Compose the sub-line from segments joined by " · " so the range drops out
|
||
// cleanly when there are no year bands; the whole line is absent when the
|
||
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
||
// TimelineView.
|
||
const metaLine = $derived.by(() => {
|
||
const segments: string[] = [];
|
||
if (meta.firstYear !== null && meta.lastYear !== null) {
|
||
segments.push(`${meta.firstYear}–${meta.lastYear}`);
|
||
}
|
||
// A zero-count segment ("0 Briefe") reads as a data error — drop it; a count
|
||
// of one takes the singular key ("1 Brief"), per the project plural convention.
|
||
if (meta.letterCount > 0) {
|
||
segments.push(
|
||
meta.letterCount === 1
|
||
? m.timeline_letters_count_singular()
|
||
: m.timeline_letters_count({ count: meta.letterCount })
|
||
);
|
||
}
|
||
if (meta.eventCount > 0) {
|
||
segments.push(
|
||
meta.eventCount === 1
|
||
? m.timeline_events_count_singular()
|
||
: m.timeline_events_count({ count: meta.eventCount })
|
||
);
|
||
}
|
||
// REQ-011: the toggle-free chronological view carries no grouping segment.
|
||
return segments.join(' · ');
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{m.timeline_heading()}</title>
|
||
</svelte:head>
|
||
|
||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||
<!-- The .tl-canvas sheet: a padded canvas surface for the timeline. The outer
|
||
border is intentionally omitted (the page is already bg-canvas), per the
|
||
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
|
||
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
|
||
<!-- Wrapping header so the CTA drops below the heading at narrow widths
|
||
(≤360px) instead of overflowing — #842 REQ-001. -->
|
||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||
{#if data.canWrite}
|
||
<a
|
||
data-testid="timeline-add-event"
|
||
href="/zeitstrahl/events/new"
|
||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||
>
|
||
{m.timeline_add_event()}
|
||
</a>
|
||
{/if}
|
||
</header>
|
||
{#if hasContent}
|
||
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
|
||
<TimelineFilters
|
||
bind:personalOn={personalOn}
|
||
bind:historicalOn={historicalOn}
|
||
bind:lettersOn={lettersOn}
|
||
/>
|
||
{/if}
|
||
{#if hasContent && filteredEmpty}
|
||
<!-- Filtered-empty: a calm message + one-click reset below the still-open
|
||
filter bar — never a blank page, and never the generic "no events"
|
||
state (which would imply the archive itself is empty). REQ-006. -->
|
||
<div data-testid="timeline-filter-empty" class="py-12 text-center">
|
||
<p class="font-serif text-base text-ink-2">{m.timeline_filter_empty_state()}</p>
|
||
<button
|
||
type="button"
|
||
data-testid="timeline-filter-empty-reset"
|
||
onclick={resetFilters}
|
||
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
|
||
>
|
||
{m.timeline_filter_reset()}
|
||
</button>
|
||
</div>
|
||
{:else}
|
||
<TimelineView timeline={filteredTimeline} canWrite={data.canWrite} />
|
||
{/if}
|
||
</div>
|
||
</div>
|