feat(timeline): render a same-year curated event as its cluster card header

A curated event with letters in its own band now becomes the contained card header
(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —
the title reads once. Derived life-events, world-bands, and letterless event pills
are unchanged (REQ-001 amended for curated-with-letters; the identity fixture now
links its letter to the curated event so the letterless world band stays a band).

Refs #827
This commit is contained in:
Marcel
2026-06-15 14:56:57 +02:00
parent e100213760
commit 70794616d2
5 changed files with 181 additions and 43 deletions

View File

@@ -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}
<header
class="flex items-center gap-2 px-3 py-2"
class:bg-canvas={isEventCluster}
class:border-b={!isDrawer}
class:border-line={!isDrawer}
>
{#if mode === 'thema' && bucket.kind === 'tag'}
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
{:else if mode === 'event' && bucket.kind === 'event'}
<span class="font-serif text-sm font-bold text-ink">
<span aria-hidden="true"></span>
{bucket.title}
{#if event && accent}
<!-- A same-year curated event IS the card header — its title reads once here, never
also as a floating pill (#827 redesign, REQ-001/014). Glyph is aria-hidden with an
sr-only label sibling (REQ-018); the edit pencil mirrors EventPill's gate. -->
<header
data-testid="bucket-event-header"
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {accent.accent ===
'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<span aria-hidden="true">{accent.glyph}</span>
<span class="sr-only">{accent.label}</span>
</span>
{:else}
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
{/if}
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
</header>
<span class="min-w-0 text-left">
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{event.title}</span
>
<span class="block font-sans text-xs text-ink-3">
{eventSubtitle} <span data-testid="bucket-count">· {count}</span>
</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{event.eventId}/edit"
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}
</header>
{:else}
<header
class="flex items-center gap-2 px-3 py-2"
class:bg-canvas={isEventCluster}
class:border-b={!isDrawer}
class:border-line={!isDrawer}
>
{#if mode === 'thema' && bucket.kind === 'tag'}
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
{:else if mode === 'event' && bucket.kind === 'event'}
<span class="font-serif text-sm font-bold text-ink">
<span aria-hidden="true"></span>
{bucket.title}
</span>
{:else}
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
{/if}
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
</header>
{/if}
{/if}
<div class="px-3 py-2">

View File

@@ -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();
});
});

View File

@@ -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<Row[]>(() => {
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}
<EventPill entry={row.entry} canWrite={canWrite} />
{/if}
{:else if row.t === 'eventcard'}
<LetterBucket
bucket={row.bucket}
mode="event"
year={year.year}
event={row.entry}
canWrite={canWrite}
/>
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>

View File

@@ -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();
});

View File

@@ -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',