feat(timeline): add the LetterBucket cluster component

Renders one loose-letter cluster for Ereignis/Thema mode (#827): an
"✉ <event> · <n>" header over .lcard.ev cards in Ereignis, a tinted
BucketHeaderChip over chip-suppressed cards in Thema, and a localized
"Weitere Briefe"/"Ohne Thema" header with plain cards for the fallback bucket.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 10:42:45 +02:00
parent fd67a21610
commit f3c2465465
2 changed files with 123 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import BucketHeaderChip from './BucketHeaderChip.svelte';
import { entryKey } from './entryKey';
import type { LetterBucket } from './timelineGrouping';
/**
* One cluster of loose letters under a header, in Ereignis or Thema mode (#827). The axis-fixed
* event/world-band layers are rendered elsewhere — this is only the loose-letter bundling.
* Ereignis: a "✉ <event> · <n>" header over `.lcard.ev` cards (REQ-003/014). Thema: a tinted
* root-tag header chip over cards whose own tag chip is suppressed (REQ-004/015/017). A bucket
* with no title (kind `fallback`) uses the localized "Weitere Briefe"/"Ohne Thema" label and
* keeps its letters as plain cards (REQ-006/007).
*/
let { bucket, mode }: { bucket: LetterBucket; mode: 'event' | 'thema' } = $props();
const count = $derived(bucket.letters.length);
const fallbackLabel = $derived(
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
);
</script>
<section class="my-3" data-testid="letter-bucket" data-bucket-kind={bucket.kind}>
<header class="mb-2 flex items-center gap-2">
{#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>
<ul class="space-y-2">
{#each bucket.letters as letter (entryKey(letter))}
<li>
{#if mode === 'event'}
<LetterCard entry={letter} variant={bucket.kind === 'event' ? 'event' : 'plain'} />
{:else}
<LetterCard entry={letter} suppressTagChip={true} />
{/if}
</li>
{/each}
</ul>
</section>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import LetterBucket from './LetterBucket.svelte';
import { makeEntry } from './test-factories';
import type { LetterBucket as Bucket } from './timelineGrouping';
afterEach(() => cleanup());
const eventBucket: Bucket = {
key: 'event:e1',
kind: 'event',
title: 'Briefe von der Front',
color: null,
letters: [makeEntry({ documentId: 'a' }), makeEntry({ documentId: 'b' })]
};
const tagBucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Krieg',
color: 'sienna',
letters: [makeEntry({ documentId: 'c', rootTagName: 'Krieg', rootTagColor: 'sienna' })]
};
describe('LetterBucket — Ereignis mode (REQ-003/006/014)', () => {
it('shows the event title and the cluster count', () => {
render(LetterBucket, { bucket: eventBucket, mode: 'event' });
expect(document.body.textContent).toContain('Briefe von der Front');
expect(document.querySelector('[data-testid="bucket-count"]')?.textContent).toContain('2');
});
it('renders its letters as .lcard.ev event cards (REQ-014)', () => {
render(LetterBucket, { bucket: eventBucket, mode: 'event' });
expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(2);
});
it('uses the localized "Weitere Briefe" label and plain cards for the fallback bucket (REQ-006)', () => {
const fb: Bucket = {
key: '__fallback__',
kind: 'fallback',
color: null,
letters: [makeEntry({ documentId: 'x' })]
};
render(LetterBucket, { bucket: fb, mode: 'event' });
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
// fallback letters are not clustered under a curated event → plain card, never .lcard.ev
expect(document.querySelector('a.ev')).toBeNull();
});
});
describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => {
it('renders a tinted bucket-header chip carrying the root-tag name (REQ-015)', () => {
render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
const chip = document.querySelector('[data-testid="bucket-header-chip"]');
expect(chip?.textContent).toContain('Krieg');
});
it('suppresses the per-letter tag chip inside its own root-tag bucket (REQ-017)', () => {
render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
});
it('uses the localized "Ohne Thema" label for the untagged fallback bucket (REQ-007)', () => {
const fb: Bucket = {
key: '__fallback__',
kind: 'fallback',
color: null,
letters: [makeEntry({ documentId: 'y', rootTagName: undefined })]
};
render(LetterBucket, { bucket: fb, mode: 'thema' });
expect(document.body.textContent).toContain(m.timeline_bucket_no_topic());
});
});