feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847
@@ -2,48 +2,83 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import { entryKey } from './entryKey';
|
||||
import type { LetterBucket } from './timelineGrouping';
|
||||
import { isBucketDense, tagColorVar, 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).
|
||||
* 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
|
||||
* 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).
|
||||
*
|
||||
* A bucket larger than the density threshold collapses to the month-density `YearLetterStrip`
|
||||
* instead of flooding the timeline with every card (#827) — the catch-all buckets are the biggest.
|
||||
*/
|
||||
let { bucket, mode }: { bucket: LetterBucket; mode: 'event' | 'thema' } = $props();
|
||||
let {
|
||||
bucket,
|
||||
mode,
|
||||
year = 0,
|
||||
nested = false
|
||||
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
|
||||
|
||||
const count = $derived(bucket.letters.length);
|
||||
const dense = $derived(isBucketDense(count));
|
||||
const fallbackLabel = $derived(
|
||||
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
|
||||
);
|
||||
// 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);
|
||||
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
|
||||
const isEventCluster = $derived(nested || bucket.kind === 'event');
|
||||
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
|
||||
</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
|
||||
class="my-3 border-l-2 pl-3"
|
||||
class:border-l-brand-mint={isEventCluster}
|
||||
class:border-line={!railColor && !isEventCluster}
|
||||
style={railStyle}
|
||||
data-testid="letter-bucket"
|
||||
data-bucket-kind={bucket.kind}
|
||||
>
|
||||
{#if !nested}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
{#if dense}
|
||||
<!-- Oversized bucket → the month-density strip (count + sparkline + expand), not a flood. -->
|
||||
<YearLetterStrip letters={bucket.letters} year={year} />
|
||||
{:else}
|
||||
<ul class="space-y-1.5">
|
||||
{#each bucket.letters as letter (entryKey(letter))}
|
||||
<li>
|
||||
<LetterCard
|
||||
entry={letter}
|
||||
variant={cardVariant}
|
||||
suppressTagChip={mode === 'thema'}
|
||||
compact={true}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -72,3 +72,65 @@ describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => {
|
||||
expect(document.body.textContent).toContain(m.timeline_bucket_no_topic());
|
||||
});
|
||||
});
|
||||
|
||||
const manyLetters = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
|
||||
);
|
||||
|
||||
describe('LetterBucket — density + containment (#827)', () => {
|
||||
it('collapses an oversized bucket to the density strip instead of flooding cards', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Sonstiges',
|
||||
color: null,
|
||||
letters: manyLetters(10)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
|
||||
// not ten individual cards dumped into the timeline
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders compact cards for a small bucket (no strip)', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Tod',
|
||||
color: null,
|
||||
letters: manyLetters(2)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
|
||||
expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('omits the header when nested — the event pill above is the header', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'event:e1',
|
||||
kind: 'event',
|
||||
title: 'Ein gewaltiger Stadtbrand',
|
||||
color: null,
|
||||
letters: manyLetters(1)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'event', nested: true, year: 1916 });
|
||||
expect(document.querySelector('[data-testid="bucket-count"]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('Ein gewaltiger Stadtbrand');
|
||||
// still the event-letter variant, just headerless under its pill
|
||||
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('binds a tag bucket together with a coloured left rail from its token', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Krieg',
|
||||
color: 'sienna',
|
||||
letters: manyLetters(1)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
const section = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,41 @@ export type GroupingMode = 'date' | 'event' | 'thema';
|
||||
/** The default mode — chronological, as #779 shipped. */
|
||||
export const DEFAULT_GROUPING: GroupingMode = 'date';
|
||||
|
||||
/**
|
||||
* A bucket larger than this collapses to a month-density strip instead of flooding the
|
||||
* timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema"
|
||||
* buckets are always the biggest, so without this they swamp the grouped view. Lower than
|
||||
* Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year.
|
||||
*/
|
||||
export const BUCKET_DENSE_THRESHOLD = 6;
|
||||
|
||||
export function isBucketDense(letterCount: number): boolean {
|
||||
return letterCount > BUCKET_DENSE_THRESHOLD;
|
||||
}
|
||||
|
||||
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
||||
const TAG_COLOR_TOKENS = new Set([
|
||||
'sage',
|
||||
'sienna',
|
||||
'amber',
|
||||
'slate',
|
||||
'violet',
|
||||
'rose',
|
||||
'cobalt',
|
||||
'moss',
|
||||
'sand',
|
||||
'coral'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Maps a root-tag colour-name token to its CSS variable reference, or `null` for an absent
|
||||
* or unknown token (so a colourless/unrecognised tag falls back to a neutral rail, never a
|
||||
* broken `var(--c-tag-undefined)`).
|
||||
*/
|
||||
export function tagColorVar(token: string | null | undefined): string | null {
|
||||
return token && TAG_COLOR_TOKENS.has(token) ? `var(--c-tag-${token})` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bundle of loose letters under a single header, within a year (Ereignis/Thema modes).
|
||||
* `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized
|
||||
|
||||
Reference in New Issue
Block a user