From bd78f34f0997bfab4563dfb5f9aadafef3a58942 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 14:56:57 +0200 Subject: [PATCH] feat(timeline): render a same-year curated event as its cluster card header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/lib/timeline/LetterBucket.svelte | 113 ++++++++++++++---- .../lib/timeline/LetterBucket.svelte.spec.ts | 52 ++++++++ frontend/src/lib/timeline/YearBand.svelte | 41 ++++--- .../src/lib/timeline/YearBand.svelte.spec.ts | 11 +- ...ouping-event-layer-identity.svelte.spec.ts | 7 +- 5 files changed, 181 insertions(+), 43 deletions(-) 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.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',