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:
@@ -3,18 +3,24 @@ import * as m from '$lib/paraglide/messages.js';
|
|||||||
import LetterCard from './LetterCard.svelte';
|
import LetterCard from './LetterCard.svelte';
|
||||||
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||||
import { entryKey } from './entryKey';
|
import { entryKey } from './entryKey';
|
||||||
|
import { getAccentConfig } from './eventCardConfig';
|
||||||
|
import { timelineDateLabel } from './dateLabel';
|
||||||
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
|
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
|
* 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.
|
* loose-letter bundling.
|
||||||
*
|
*
|
||||||
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
|
* - 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).
|
* tag chip is suppressed (REQ-004/015/017).
|
||||||
* - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the
|
* - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date,
|
||||||
* header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" /
|
* provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign,
|
||||||
* "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007).
|
* 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
|
* 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).
|
* 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.
|
// time from the band heading). Kept in the prop contract for callers/tests.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
year = 0,
|
year = 0,
|
||||||
nested = false
|
nested = false,
|
||||||
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
|
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 count = $derived(bucket.letters.length);
|
||||||
const fallbackLabel = $derived(
|
const fallbackLabel = $derived(
|
||||||
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
|
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,
|
// 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).
|
// neutral for the fallback (and for a colourless/unknown tag token).
|
||||||
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
|
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
|
||||||
@@ -65,6 +94,43 @@ let revealed = $state(bucket.kind !== 'fallback');
|
|||||||
data-bucket-kind={bucket.kind}
|
data-bucket-kind={bucket.kind}
|
||||||
>
|
>
|
||||||
{#if !nested}
|
{#if !nested}
|
||||||
|
{#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>
|
||||||
|
<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
|
<header
|
||||||
class="flex items-center gap-2 px-3 py-2"
|
class="flex items-center gap-2 px-3 py-2"
|
||||||
class:bg-canvas={isEventCluster}
|
class:bg-canvas={isEventCluster}
|
||||||
@@ -84,6 +150,7 @@ let revealed = $state(bucket.kind !== 'fallback');
|
|||||||
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
||||||
</header>
|
</header>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="px-3 py-2">
|
<div class="px-3 py-2">
|
||||||
{#if !revealed}
|
{#if !revealed}
|
||||||
|
|||||||
@@ -178,3 +178,55 @@ describe('LetterBucket — card chrome (#827 redesign)', () => {
|
|||||||
expect(card.className).toContain('bg-surface');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ let {
|
|||||||
|
|
||||||
type Row =
|
type Row =
|
||||||
| { t: 'event'; entry: TimelineEntryDTO }
|
| { t: 'event'; entry: TimelineEntryDTO }
|
||||||
|
| { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
|
||||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
||||||
| { t: 'strip' }
|
| { t: 'strip' }
|
||||||
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
|
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
|
||||||
@@ -52,26 +53,30 @@ const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
|
|||||||
const rows = $derived.by<Row[]>(() => {
|
const rows = $derived.by<Row[]>(() => {
|
||||||
const out: Row[] = [];
|
const out: Row[] = [];
|
||||||
|
|
||||||
// Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly
|
// Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
|
||||||
// beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose
|
// becomes the contained card's header (no separate pill — its title reads once, #827
|
||||||
// pill lives in another year band (or was filtered out) keeps its own header here, and the
|
// redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
|
||||||
// unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019).
|
// 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') {
|
if (groupingMode === 'event') {
|
||||||
const buckets = bucketLetters(letters, 'event', eventLookup);
|
const buckets = bucketLetters(letters, 'event', eventLookup);
|
||||||
const hasPill = (bucketKey: string) =>
|
const sameYearBucket = (id: string | undefined) =>
|
||||||
year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey);
|
id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
|
||||||
// Each pill renders, then its same-year cluster nests directly beneath it (no header).
|
|
||||||
for (const entry of year.entries) {
|
for (const entry of year.entries) {
|
||||||
if (entry.kind !== 'EVENT') continue;
|
if (entry.kind !== 'EVENT') continue;
|
||||||
out.push({ t: 'event', entry });
|
const bucket = sameYearBucket(entry.eventId);
|
||||||
const bucket = entry.eventId
|
// A curated event with same-year letters becomes the card header (card replaces pill);
|
||||||
? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`)
|
// otherwise it stays a plain pill/world-band.
|
||||||
: undefined;
|
if (bucket) out.push({ t: 'eventcard', entry, bucket });
|
||||||
if (bucket) out.push({ t: 'bucket', bucket, nested: true });
|
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) {
|
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 });
|
out.push({ t: 'bucket', bucket, nested: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +136,14 @@ function rowKey(row: Row): string {
|
|||||||
{:else}
|
{:else}
|
||||||
<EventPill entry={row.entry} canWrite={canWrite} />
|
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||||
{/if}
|
{/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'}
|
{:else if row.t === 'letter'}
|
||||||
<div class="letter-row" data-side={row.side}>
|
<div class="letter-row" data-side={row.side}>
|
||||||
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ describe('YearBand — grouping modes (#827)', () => {
|
|||||||
expect(chip?.textContent).toContain('Krieg');
|
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({
|
const pill = makeEntry({
|
||||||
kind: 'EVENT',
|
kind: 'EVENT',
|
||||||
type: 'PERSONAL',
|
type: 'PERSONAL',
|
||||||
@@ -238,14 +238,15 @@ describe('YearBand — grouping modes (#827)', () => {
|
|||||||
render(YearBand, {
|
render(YearBand, {
|
||||||
year: makeYear(1916, [pill, letter]),
|
year: makeYear(1916, [pill, letter]),
|
||||||
groupingMode: 'event',
|
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 =
|
const occurrences =
|
||||||
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
||||||
expect(occurrences).toBe(1);
|
expect(occurrences).toBe(1);
|
||||||
// the letter is still clustered (nested under the pill) as the event-letter card
|
// the event renders as the card header, with its letter clustered inside
|
||||||
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
|
||||||
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () =>
|
const mixed = () =>
|
||||||
makeTimelineDTO({
|
makeTimelineDTO({
|
||||||
years: [
|
years: [
|
||||||
makeYear(1915, [
|
makeYear(1915, [
|
||||||
worldBand('Erster Weltkrieg'),
|
worldBand('Erster Weltkrieg'),
|
||||||
eventPill('Hochzeit'),
|
eventPill('Hochzeit'),
|
||||||
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }),
|
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }),
|
||||||
makeEntry({
|
makeEntry({
|
||||||
documentId: 'b',
|
documentId: 'b',
|
||||||
title: 'Brief B',
|
title: 'Brief B',
|
||||||
|
|||||||
Reference in New Issue
Block a user