diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte
index 12fb85a9..0c86c43c 100644
--- a/frontend/src/lib/timeline/LetterBucket.svelte
+++ b/frontend/src/lib/timeline/LetterBucket.svelte
@@ -3,18 +3,24 @@ import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import BucketHeaderChip from './BucketHeaderChip.svelte';
import { entryKey } from './entryKey';
+import { getAccentConfig } from './eventCardConfig';
+import { timelineDateLabel } from './dateLabel';
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
+import type { components } from '$lib/generated/api';
+
+type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
- * unit (#827). The axis-fixed event/world-band layers are rendered elsewhere — this is only the
+ * unit (#827). The axis-fixed world-band layer is rendered elsewhere — this is only the
* loose-letter bundling.
*
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
* tag chip is suppressed (REQ-004/015/017).
- * - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the
- * header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" /
- * "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007).
+ * - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date,
+ * provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign,
+ * REQ-001/014). A cross-year cluster keeps a plain text header. The standalone "Weitere Briefe"
+ * / "Ohne Thema" fallback keeps its label and a neutral dashed rail (REQ-006/007).
*
* A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest
* instead of flooding the timeline with every card (#827 redesign).
@@ -27,13 +33,36 @@ let {
// time from the band heading). Kept in the prop contract for callers/tests.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
year = 0,
- nested = false
-}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
+ nested = false,
+ event = undefined,
+ canWrite = false
+}: {
+ bucket: LetterBucket;
+ mode: 'event' | 'thema';
+ year?: number;
+ nested?: boolean;
+ /** The same-year curated event whose letters this card holds — renders as the header. */
+ event?: TimelineEntryDTO;
+ canWrite?: boolean;
+} = $props();
const count = $derived(bucket.letters.length);
const fallbackLabel = $derived(
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
);
+
+// Event-as-header (#827 redesign): a same-year curated event renders as this card's header,
+// mirroring EventPill — glyph + title + date · provenance + an edit pencil for a curator. The
+// title is never repeated as a separate floating pill.
+const accent = $derived(event ? getAccentConfig(event) : null);
+const eventDateLabel = $derived(
+ event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null
+);
+const provenance = $derived(
+ event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
+);
+const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
+const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster,
// neutral for the fallback (and for a colourless/unknown tag token).
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
@@ -65,24 +94,62 @@ let revealed = $state(bucket.kind !== 'fallback');
data-bucket-kind={bucket.kind}
>
{#if !nested}
-
- {#if mode === 'thema' && bucket.kind === 'tag'}
-
- {:else if mode === 'event' && bucket.kind === 'event'}
-
- ✉
- {bucket.title}
+ {#if event && accent}
+
+
+
+ {accent.glyph}
+ {accent.label}
- {:else}
- {fallbackLabel}
- {/if}
- · {count}
-
+
+ {event.title}
+
+ {eventSubtitle} · {count}
+
+
+ {#if canEdit}
+
+ ✎
+ {m.btn_edit()}
+
+ {/if}
+
+ {:else}
+
+ {#if mode === 'thema' && bucket.kind === 'tag'}
+
+ {:else if mode === 'event' && bucket.kind === 'event'}
+
+ ✉
+ {bucket.title}
+
+ {:else}
+ {fallbackLabel}
+ {/if}
+ · {count}
+
+ {/if}
{/if}
diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
index 854460f5..326a4683 100644
--- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
+++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
@@ -178,3 +178,55 @@ describe('LetterBucket — card chrome (#827 redesign)', () => {
expect(card.className).toContain('bg-surface');
});
});
+
+describe('LetterBucket — event-as-header (#827 redesign)', () => {
+ it('renders the curated event as the card header when given an `event` (no separate pill)', () => {
+ const event = makeEntry({
+ kind: 'EVENT',
+ type: 'PERSONAL',
+ derived: false,
+ eventId: 'e1',
+ title: 'Ein gewaltiger Stadtbrand',
+ eventDate: '1916-07-06',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ });
+ const bucket: Bucket = {
+ key: 'event:e1',
+ kind: 'event',
+ title: 'Ein gewaltiger Stadtbrand',
+ color: null,
+ letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })]
+ };
+ render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true });
+ const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement;
+ expect(header.textContent).toContain('Ein gewaltiger Stadtbrand');
+ expect(header.textContent).toContain(m.timeline_provenance_curated());
+ expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe(
+ '/zeitstrahl/events/e1/edit'
+ );
+ });
+
+ it('shows no edit affordance in the header when canWrite is false', () => {
+ const event = makeEntry({
+ kind: 'EVENT',
+ type: 'PERSONAL',
+ derived: false,
+ eventId: 'e1',
+ title: 'X',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ });
+ const bucket: Bucket = {
+ key: 'event:e1',
+ kind: 'event',
+ title: 'X',
+ color: null,
+ letters: [makeEntry({ documentId: 'a' })]
+ };
+ render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false });
+ expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte
index 17f704a4..f643885b 100644
--- a/frontend/src/lib/timeline/YearBand.svelte
+++ b/frontend/src/lib/timeline/YearBand.svelte
@@ -41,6 +41,7 @@ let {
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
+ | { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' }
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
@@ -52,26 +53,30 @@ const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
const rows = $derived.by
(() => {
const out: Row[] = [];
- // Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly
- // beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose
- // pill lives in another year band (or was filtered out) keeps its own header here, and the
- // unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019).
+ // Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
+ // becomes the contained card's header (no separate pill — its title reads once, #827
+ // redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
+ // lives in another year band (or was filtered out) renders as a text-header card here, and
+ // the unlinked letters fall to the single "Weitere Briefe" drawer (REQ-003/006/019).
if (groupingMode === 'event') {
const buckets = bucketLetters(letters, 'event', eventLookup);
- const hasPill = (bucketKey: string) =>
- year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey);
- // Each pill renders, then its same-year cluster nests directly beneath it (no header).
+ const sameYearBucket = (id: string | undefined) =>
+ id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
for (const entry of year.entries) {
if (entry.kind !== 'EVENT') continue;
- out.push({ t: 'event', entry });
- const bucket = entry.eventId
- ? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`)
- : undefined;
- if (bucket) out.push({ t: 'bucket', bucket, nested: true });
+ const bucket = sameYearBucket(entry.eventId);
+ // A curated event with same-year letters becomes the card header (card replaces pill);
+ // otherwise it stays a plain pill/world-band.
+ if (bucket) out.push({ t: 'eventcard', entry, bucket });
+ else out.push({ t: 'event', entry });
}
- // Clusters whose pill is in another band keep their header; then the fallback, last.
+ // Cross-year clusters (no matching event entry in this band) and the fallback drawer
+ // render after the axis entries, with their own text header.
for (const bucket of buckets) {
- if (bucket.kind === 'fallback' || !hasPill(bucket.key)) {
+ if (
+ bucket.kind === 'fallback' ||
+ !year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key)
+ ) {
out.push({ t: 'bucket', bucket, nested: false });
}
}
@@ -131,6 +136,14 @@ function rowKey(row: Row): string {
{:else}
{/if}
+ {:else if row.t === 'eventcard'}
+
{:else if row.t === 'letter'}
diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts
index 9f2a8ece..c8fdae0f 100644
--- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts
+++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts
@@ -222,7 +222,7 @@ describe('YearBand — grouping modes (#827)', () => {
expect(chip?.textContent).toContain('Krieg');
});
- it('nests an event cluster under its pill in the same year without repeating the title (#827)', () => {
+ it('renders a same-year curated event as one card header, with no separate pill and no duplicate title (#827)', () => {
const pill = makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
@@ -238,14 +238,15 @@ describe('YearBand — grouping modes (#827)', () => {
render(YearBand, {
year: makeYear(1916, [pill, letter]),
groupingMode: 'event',
- eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']])
+ eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]),
+ canWrite: true
});
- // the title appears exactly once — on the axis pill, NOT also as a bucket header
+ // the title appears exactly once — in the card header, not also as a separate pill
const occurrences =
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
expect(occurrences).toBe(1);
- // the letter is still clustered (nested under the pill) as the event-letter card
- expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
+ // the event renders as the card header, with its letter clustered inside
+ expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
});
diff --git a/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts
index a430ebaa..7f2a724d 100644
--- a/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts
+++ b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts
@@ -46,13 +46,18 @@ function eventLayerSignature(): string {
});
}
+// Brief A links to the curated event p1 (Hochzeit), not the world band — so the world band
+// stays letterless and renders as a plain band in every mode (REQ-001). Under the #827 redesign
+// a curated event WITH letters becomes its cluster card's header, so the signature tracks the
+// stable layer: the letterless world band's marker count and the two titles, which all survive
+// regardless of whether Hochzeit renders as a pill (Datum) or a card header (grouped).
const mixed = () =>
makeTimelineDTO({
years: [
makeYear(1915, [
worldBand('Erster Weltkrieg'),
eventPill('Hochzeit'),
- makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }),
+ makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }),
makeEntry({
documentId: 'b',
title: 'Brief B',