Compare commits
5 Commits
093c942f67
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4bf8edc0 | ||
|
|
70794616d2 | ||
|
|
e100213760 | ||
|
|
bea9acfe63 | ||
|
|
5a8bee3970 |
@@ -209,10 +209,10 @@
|
||||
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
|
||||
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
|
||||
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
|
||||
| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant, **nested directly under its event pill** with no duplicate title (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards`, `YearBand.svelte.spec.ts#nests an event cluster under its pill in the same year without repeating the title` | Done |
|
||||
| REQ-014 | Ereignis event-clustered letters live inside a **contained card whose header is the same-year curated event** (glyph, title, date, provenance, edit pencil) — the title reads once, no separate floating pill; letters render as the compact `.lcard.ev` variant, first 5 + show-more (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders the curated event as the card header when given an `event` (no separate pill)`, `LetterBucket.svelte.spec.ts#shows no edit affordance in the header when canWrite is false`, `YearBand.svelte.spec.ts#renders a same-year curated event as one card header, with no separate pill and no duplicate title` | Done |
|
||||
| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done |
|
||||
| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done |
|
||||
| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done |
|
||||
| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done |
|
||||
| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done |
|
||||
| REQ-020 | Grouped buckets are bound by a colour rail and carry compact cards; a bucket over `BUCKET_DENSE_THRESHOLD` (6) collapses to the month-density `YearLetterStrip` instead of flooding the timeline (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#isBucketDense`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#collapses an oversized bucket to the density strip instead of flooding cards`, `#binds a tag bucket together with a coloured left rail from its token`, `#renders compact cards for a small bucket`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
|
||||
| REQ-020 | Grouped clusters are **contained colour-railed cards** (bordered, rounded, surface) carrying compact cards; a cluster shows the first `CLUSTER_PREVIEW` (5) letters behind a show-more toggle, and the leftover bin is a **collapsed count-only drawer** revealed on demand — the month-density `YearLetterStrip` is no longer used in grouped mode (still used in Datum dense years) (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#CLUSTER_PREVIEW`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#renders the cluster as a contained card (bordered, rounded, surface)`, `#binds a tag bucket together with a coloured left rail from its token`, `#shows only the first 5 letters with a show-more toggle when the cluster is larger`, `#expands to all letters and collapses back on toggle`, `#renders collapsed — count + reveal, no letter cards — until opened`, `#reveals the first 5 letters when opened`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
|
||||
|
||||
@@ -1063,6 +1063,8 @@
|
||||
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
|
||||
"timeline_bucket_other_letters": "Weitere Briefe",
|
||||
"timeline_bucket_no_topic": "Ohne Thema",
|
||||
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||
"timeline_provenance_derived": "abgeleitet",
|
||||
"timeline_provenance_curated": "kuratiert",
|
||||
"timeline_letter_glyph_label": "Brief",
|
||||
|
||||
@@ -1063,6 +1063,8 @@
|
||||
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
|
||||
"timeline_bucket_other_letters": "More letters",
|
||||
"timeline_bucket_no_topic": "No topic",
|
||||
"timeline_bucket_show_more": "+ {count} more letters",
|
||||
"timeline_bucket_show_less": "Show fewer",
|
||||
"timeline_provenance_derived": "derived",
|
||||
"timeline_provenance_curated": "curated",
|
||||
"timeline_letter_glyph_label": "Letter",
|
||||
|
||||
@@ -1063,6 +1063,8 @@
|
||||
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
|
||||
"timeline_bucket_other_letters": "Más cartas",
|
||||
"timeline_bucket_no_topic": "Sin tema",
|
||||
"timeline_bucket_show_more": "+ {count} cartas más",
|
||||
"timeline_bucket_show_less": "Mostrar menos",
|
||||
"timeline_provenance_derived": "derivado",
|
||||
"timeline_provenance_curated": "curado",
|
||||
"timeline_letter_glyph_label": "Carta",
|
||||
|
||||
@@ -2,83 +2,194 @@
|
||||
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 { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* - 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).
|
||||
* - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date,
|
||||
* provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign,
|
||||
* 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 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.
|
||||
* 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).
|
||||
*/
|
||||
let {
|
||||
bucket,
|
||||
mode,
|
||||
// `year` is the band's year — accepted for the cross-year label card seam (#827) but no
|
||||
// longer consumed here now the in-bucket month-density strip is gone (the year frames the
|
||||
// time from the band heading). Kept in the prop contract for callers/tests.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
year = 0,
|
||||
nested = false
|
||||
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
|
||||
nested = false,
|
||||
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 dense = $derived(isBucketDense(count));
|
||||
const fallbackLabel = $derived(
|
||||
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,
|
||||
// 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');
|
||||
|
||||
// First-5 preview + show-more (#827 redesign): a large cluster stays readable instead of
|
||||
// dumping every card into the timeline.
|
||||
let expanded = $state(false);
|
||||
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
|
||||
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
|
||||
|
||||
// The catch-all "Weitere Briefe" / "Ohne Thema" bin is a junk drawer: render it count-only
|
||||
// behind a reveal control so it never floods the timeline; every other cluster starts open
|
||||
// (#827 redesign). The view re-creates a bucket per `{#each}` key, so the initial capture is
|
||||
// the right lifetime — `revealed` belongs to this bucket instance.
|
||||
const isDrawer = $derived(bucket.kind === 'fallback');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let revealed = $state(bucket.kind !== 'fallback');
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="my-3 border-l-2 pl-3"
|
||||
class="my-3 overflow-hidden rounded-md border border-l-2 border-line bg-surface shadow-sm"
|
||||
class:border-l-brand-mint={isEventCluster}
|
||||
class:border-line={!railColor && !isEventCluster}
|
||||
class:border-dashed={isDrawer}
|
||||
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}
|
||||
{#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>
|
||||
{: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>
|
||||
<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
|
||||
class="flex items-center gap-2 px-3 py-2"
|
||||
class:bg-canvas={isEventCluster}
|
||||
class:border-b={!isDrawer}
|
||||
class:border-line={!isDrawer}
|
||||
>
|
||||
{#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}
|
||||
|
||||
{#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}
|
||||
<div class="px-3 py-2">
|
||||
{#if !revealed}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bucket-reveal"
|
||||
onclick={() => (revealed = true)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
|
||||
</button>
|
||||
{:else}
|
||||
<ul class="space-y-1.5">
|
||||
{#each visible as letter (entryKey(letter))}
|
||||
<li>
|
||||
<LetterCard
|
||||
entry={letter}
|
||||
variant={cardVariant}
|
||||
suppressTagChip={mode === 'thema'}
|
||||
compact={true}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hiddenCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bucket-show-more"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{expanded
|
||||
? m.timeline_bucket_show_less()
|
||||
: m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterBucket from './LetterBucket.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
@@ -78,47 +79,49 @@ const manyLetters = (n: number) =>
|
||||
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', () => {
|
||||
describe('LetterBucket — preview cap + show-more (#827 redesign)', () => {
|
||||
it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Sonstiges',
|
||||
color: null,
|
||||
letters: manyLetters(10)
|
||||
title: 'Krieg',
|
||||
color: 'sienna',
|
||||
letters: manyLetters(8)
|
||||
};
|
||||
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);
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone
|
||||
});
|
||||
|
||||
it('renders compact cards for a small bucket (no strip)', () => {
|
||||
it('expands to all letters and collapses back on toggle', async () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Krieg',
|
||||
color: 'sienna',
|
||||
letters: manyLetters(8)
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(8);
|
||||
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('shows all letters and no toggle for a small cluster (<= 5)', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Tod',
|
||||
color: null,
|
||||
letters: manyLetters(2)
|
||||
letters: manyLetters(3)
|
||||
};
|
||||
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();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('binds a tag bucket together with a coloured left rail from its token', () => {
|
||||
@@ -134,3 +137,96 @@ describe('LetterBucket — density + containment (#827)', () => {
|
||||
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
|
||||
const fb = (n: number): Bucket => ({
|
||||
key: '__fallback__',
|
||||
kind: 'fallback',
|
||||
color: null,
|
||||
letters: Array.from({ length: n }, (_, i) =>
|
||||
makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` })
|
||||
)
|
||||
});
|
||||
it('renders collapsed — count + reveal, no letter cards — until opened', () => {
|
||||
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||
expect(document.querySelector('a.lcard')).toBeNull();
|
||||
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
|
||||
expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull();
|
||||
});
|
||||
it('reveals the first 5 letters when opened', async () => {
|
||||
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||
(document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LetterBucket — card chrome (#827 redesign)', () => {
|
||||
it('renders the cluster as a contained card (bordered, rounded, surface)', () => {
|
||||
const bucket: Bucket = {
|
||||
key: 'tag:t1',
|
||||
kind: 'tag',
|
||||
title: 'Krieg',
|
||||
color: 'sienna',
|
||||
letters: [makeEntry({ documentId: 'a' })]
|
||||
};
|
||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||
const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||
expect(card.className).toMatch(/\brounded\b|rounded-/);
|
||||
expect(card.className).toContain('border');
|
||||
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 =
|
||||
| { t: 'event'; entry: TimelineEntryDTO }
|
||||
| { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
|
||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
||||
| { t: 'strip' }
|
||||
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
|
||||
@@ -52,26 +53,30 @@ const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
|
||||
// Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly
|
||||
// beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose
|
||||
// pill lives in another year band (or was filtered out) keeps its own header here, and the
|
||||
// unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019).
|
||||
// Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
|
||||
// becomes the contained card's header (no separate pill — its title reads once, #827
|
||||
// redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
|
||||
// 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') {
|
||||
const buckets = bucketLetters(letters, 'event', eventLookup);
|
||||
const hasPill = (bucketKey: string) =>
|
||||
year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey);
|
||||
// Each pill renders, then its same-year cluster nests directly beneath it (no header).
|
||||
const sameYearBucket = (id: string | undefined) =>
|
||||
id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
|
||||
for (const entry of year.entries) {
|
||||
if (entry.kind !== 'EVENT') continue;
|
||||
out.push({ t: 'event', entry });
|
||||
const bucket = entry.eventId
|
||||
? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`)
|
||||
: undefined;
|
||||
if (bucket) out.push({ t: 'bucket', bucket, nested: true });
|
||||
const bucket = sameYearBucket(entry.eventId);
|
||||
// A curated event with same-year letters becomes the card header (card replaces pill);
|
||||
// otherwise it stays a plain pill/world-band.
|
||||
if (bucket) out.push({ t: 'eventcard', entry, bucket });
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -131,6 +136,14 @@ function rowKey(row: Row): string {
|
||||
{:else}
|
||||
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||
{/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'}
|
||||
<div class="letter-row" data-side={row.side}>
|
||||
<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');
|
||||
});
|
||||
|
||||
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({
|
||||
kind: 'EVENT',
|
||||
type: 'PERSONAL',
|
||||
@@ -238,14 +238,15 @@ describe('YearBand — grouping modes (#827)', () => {
|
||||
render(YearBand, {
|
||||
year: makeYear(1916, [pill, letter]),
|
||||
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 =
|
||||
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
// the letter is still clustered (nested under the pill) as the event-letter card
|
||||
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
||||
// the event renders as the card header, with its letter clustered inside
|
||||
expect(document.querySelector('[data-testid="bucket-event-header"]')).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 = () =>
|
||||
makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1915, [
|
||||
worldBand('Erster Weltkrieg'),
|
||||
eventPill('Hochzeit'),
|
||||
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }),
|
||||
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }),
|
||||
makeEntry({
|
||||
documentId: 'b',
|
||||
title: 'Brief B',
|
||||
|
||||
@@ -14,17 +14,8 @@ 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;
|
||||
}
|
||||
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
|
||||
export const CLUSTER_PREVIEW = 5;
|
||||
|
||||
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
||||
const TAG_COLOR_TOKENS = new Set([
|
||||
|
||||
Reference in New Issue
Block a user