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
142 lines
4.9 KiB
Svelte
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>
|