feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847
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