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:
49
frontend/src/lib/timeline/LetterBucket.svelte
Normal file
49
frontend/src/lib/timeline/LetterBucket.svelte
Normal 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>
|
||||||
74
frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
Normal file
74
frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user