Restore /zeitstrahl Datum-mode visual fidelity to the Concept-A spec (#833) #836

Merged
marcel merged 23 commits from feat/issue-833-zeitstrahl-fidelity into main 2026-06-14 14:12:42 +02:00
23 changed files with 761 additions and 26 deletions

View File

@@ -123,3 +123,19 @@
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
| REQ-001 | `/zeitstrahl` wraps the timeline in a `.tl-canvas` surface (rounded, bg-canvas, padding; outer border dropped in review — page is already bg-canvas) | #833 | zeitstrahl-visual-fidelity | `frontend/src/routes/zeitstrahl/+page.svelte` | `routes/zeitstrahl/page.svelte.spec.ts#wraps the timeline in a padded canvas surface, without an outer border` | Done |
| REQ-002 | meta sub-line: range + letter count + event count (years + undated) + "Gruppierung: Datum"; range/line omitted when empty | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/timelineMeta.ts`, `frontend/src/routes/zeitstrahl/+page.svelte` | `timelineMeta.spec.ts` (4 cases), `routes/zeitstrahl/page.svelte.spec.ts#renders the meta sub-line`, `#omits the range segment`, `#omits the entire sub-line` | Done |
| REQ-003 | year badge centered on axis ≥1024px, left spine <1024px; sticky top:4rem preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#centers the year badge on the axis at desktop`, `#left-aligns the year badge at phone width`, `#keeps the sticky year heading at top:4rem` | Done |
| REQ-004 | year badge node marker on the spine, never overlapping the badge text (desktop + phone) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders a year-badge node marker that clears the badge text on phone` | Done |
| REQ-005 | per-letter connector dot (white fill, mint ring) on the spine; phone column indented clear of card | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders one connector dot per letter row, each clearing its card on phone` | Done |
| REQ-006 | axis gradient 3-stop mint→navy→slate via `--palette-mint`/`--palette-navy`/`--c-tag-slate` | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#paints the axis with a three-stop gradient` (+ REQ-013 grep) | Done |
| REQ-007 | EventPill subtitle `{date} · {provenance}` keyed off `entry.derived` (abgeleitet/kuratiert) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#appends the "abgeleitet" provenance`, `#appends the "kuratiert" provenance`, `#never shows persönlich/SEASON` | Done |
| REQ-008 | LetterCard title prefixed with `aria-hidden` ✉ + sr-only "Brief"; href intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#prefixes a present title with an aria-hidden ✉`, `#renders an HTML-bearing title verbatim` | Done |
| REQ-009 | WorldBand inline "· historisch" descriptor (non-RANGE & RANGE); RANGE span pill + aria-label intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#appends the inline "· historisch"`, `#follows the RANGE span pill with inline "· historisch"` | Done |
| REQ-010 | YearLetterStrip count ✉ + sr-only label + "Monats-Dichte" caption; expand toggle preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#prefixes the count with an aria-hidden ✉`, `#keeps the expand toggle and its label` | Done |
| REQ-011 | YearLetterStrip exactly two endpoint month-axis labels (Jan/Dez {year}) ≥10px via formatTickLabel | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#renders exactly two endpoint month-axis labels` | Done |
| REQ-012 | undated "Ohne Datum · {count}" in a dashed frame; empty → absent; kind/type dispatch preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#frames the undated section with a dashed border and shows the count`, `#omits the "Ohne Datum" section when empty` | Done |
| REQ-013 | all new styles use semantic tokens; corrected hex grep returns zero hits | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | grep gate (REQ-013 form) → zero | Done |
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |

View File

@@ -1046,6 +1046,15 @@
"timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"timeline_grouping_date": "Gruppierung: Datum",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_letter_glyph_label": "Brief",
"timeline_layer_historical_suffix": "historisch",
"timeline_strip_density_caption": "Monats-Dichte",
"timeline_events_count": "{count} Ereignisse",
"timeline_letters_count_singular": "1 Brief",
"timeline_events_count_singular": "1 Ereignis",
"event_editor_new_title": "Neues Ereignis",
"event_editor_edit_title": "Ereignis bearbeiten",
"event_editor_section_when": "Wann",

View File

@@ -1046,6 +1046,15 @@
"timeline_derived_birth": "Birth",
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"timeline_grouping_date": "Grouping: Date",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_letter_glyph_label": "Letter",
"timeline_layer_historical_suffix": "historical",
"timeline_strip_density_caption": "Monthly density",
"timeline_events_count": "{count} events",
"timeline_letters_count_singular": "1 letter",
"timeline_events_count_singular": "1 event",
"event_editor_new_title": "New event",
"event_editor_edit_title": "Edit event",
"event_editor_section_when": "When",

View File

@@ -1046,6 +1046,15 @@
"timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"timeline_grouping_date": "Agrupación: Fecha",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_letter_glyph_label": "Carta",
"timeline_layer_historical_suffix": "histórico",
"timeline_strip_density_caption": "Densidad mensual",
"timeline_events_count": "{count} eventos",
"timeline_letters_count_singular": "1 carta",
"timeline_events_count_singular": "1 evento",
"event_editor_new_title": "Nuevo evento",
"event_editor_edit_title": "Editar evento",
"event_editor_section_when": "Cuándo",

View File

@@ -68,4 +68,26 @@ describe('message key parity', () => {
timeline_derived_marriage: 'Matrimonio'
});
});
// #833 REQ-015: the new visual-fidelity strings (meta line, provenance token,
// ✉ label, world-band suffix, density caption) are Paraglide keys present in
// every locale so no surface ever falls back to a missing translation.
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
const requiredKeys = [
'timeline_grouping_date',
'timeline_provenance_derived',
'timeline_provenance_curated',
'timeline_letter_glyph_label',
'timeline_layer_historical_suffix',
'timeline_strip_density_caption',
'timeline_events_count',
'timeline_letters_count_singular',
'timeline_events_count_singular'
];
for (const key of requiredKeys) {
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
});
});

View File

@@ -16,6 +16,14 @@ let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Provenance reads off entry.derived (not the accent): a derived life-event is
// "abgeleitet", a curated PERSONAL event is "kuratiert" (REQ-007).
const provenance = $derived(
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
// Provenance always shows; the date is an optional prefix so an undated event
// still reads "abgeleitet"/"kuratiert" (REQ-007).
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(!entry.derived && entry.eventId != null);
</script>
@@ -41,9 +49,7 @@ const canEdit = $derived(!entry.derived && entry.eventId != null);
>{entry.title}</span
>
{/if}
{#if dateLabel}
<span class="block font-sans text-xs text-ink-3">{dateLabel}</span>
{/if}
<span class="block font-sans text-xs text-ink-3">{subtitle}</span>
</span>
{#if canEdit}
<a

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import EventPill from './EventPill.svelte';
import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
@@ -87,4 +89,54 @@ describe('EventPill', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('appends the "abgeleitet" provenance token to a derived pill subtitle (REQ-007)', () => {
const entry = derived('BIRTH', 'Geburt: Hans');
const date = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
render(EventPill, { entry });
expect(document.body.textContent).toContain(`${date} · ${m.timeline_provenance_derived()}`);
});
it('appends the "kuratiert" provenance token to a curated PERSONAL pill subtitle (REQ-007)', () => {
const entry = makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: EVENT_ID,
title: 'Auswanderung',
senderName: '',
receiverName: '',
precision: 'YEAR',
eventDate: '1924-01-01',
documentId: undefined
});
const date = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
render(EventPill, { entry });
expect(document.body.textContent).toContain(`${date} · ${m.timeline_provenance_curated()}`);
});
it('never shows the spec-sheet-only "persönlich"/"SEASON" tokens (REQ-007)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
expect(document.body.textContent).not.toContain('persönlich');
expect(document.body.textContent).not.toContain('SEASON');
});
it('still shows the provenance token when the event has no date label (REQ-007)', () => {
// An undated / UNKNOWN-precision event (e.g. in the undated bucket) yields a
// null dateLabel; provenance must not be gated behind the date.
const entry = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Hans',
senderName: '',
receiverName: '',
precision: 'UNKNOWN',
eventDate: undefined,
documentId: undefined
});
expect(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)).toBeNull();
render(EventPill, { entry });
expect(document.body.textContent).toContain(m.timeline_provenance_derived());
});
});

View File

@@ -0,0 +1,12 @@
<script lang="ts">
/**
* A decorative glyph paired with a screen-reader-only text label — the
* non-color accessibility cue used across timeline cards (e.g. the ✉ on a
* letter title or letter-count). The glyph is `aria-hidden`; the label carries
* the meaning for assistive tech.
*/
let { glyph, label }: { glyph: string; label: string } = $props();
</script>
<span aria-hidden="true">{glyph}</span>
<span class="sr-only">{label}</span>

View File

@@ -0,0 +1,15 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import GlyphLabel from './GlyphLabel.svelte';
afterEach(() => cleanup());
describe('GlyphLabel', () => {
it('renders the glyph aria-hidden with an sr-only label sibling', () => {
render(GlyphLabel, { glyph: '✉', label: 'Brief' });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('✉');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Brief');
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { timelineDateLabel } from './dateLabel';
import GlyphLabel from './GlyphLabel.svelte';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
@@ -29,9 +30,13 @@ const receiver = $derived(
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{#if entry.title}
<span class="font-serif text-sm font-bold break-words whitespace-pre-line text-ink"
>{entry.title}</span
>
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
interpolated into the escaped user title; the title keeps its own
pre-line span for multi-line OCR text (REQ-008/016/021). -->
<span class="font-serif text-sm font-bold break-words text-ink">
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
<span class="whitespace-pre-line">{entry.title}</span>
</span>
{/if}
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
<span class="font-serif whitespace-pre-line">{sender}</span>

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories';
@@ -55,4 +56,34 @@ describe('LetterCard', () => {
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it('prefixes a present title with an aria-hidden ✉ and an sr-only "Brief" label (REQ-008)', () => {
render(LetterCard, { entry: makeEntry({ title: 'Brief aus Stettin', documentId: DOC_ID }) });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toContain('✉');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label());
// The glyph is decorative chrome — the document link is unchanged.
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
});
it('renders no ✉ glyph and no "Brief" label when the title is empty (REQ-016)', () => {
render(LetterCard, {
entry: makeEntry({ title: '', senderName: 'Karl', receiverName: 'Elfriede' })
});
expect(document.body.textContent).not.toContain('✉');
expect(document.querySelector('.sr-only')).toBeNull();
// The row still shows sender → receiver and the date.
expect(document.body.textContent).toContain('Karl');
expect(document.body.textContent).toContain('Elfriede');
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
});
it('renders an HTML-bearing title verbatim as text, never as markup (security, REQ-021)', () => {
const evil = '<script>alert(1)</script>';
render(LetterCard, { entry: makeEntry({ title: evil }) });
expect(document.body.textContent).toContain(evil);
expect(document.querySelector('a script')).toBeNull();
});
});

View File

@@ -59,8 +59,13 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
</ol>
{#if timeline.undated.length > 0}
<section data-testid="undated-section" class="mx-auto mt-8 max-w-3xl">
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">{m.timeline_undated_section()}</h2>
<section
data-testid="undated-section"
class="mx-auto mt-8 max-w-3xl rounded-sm border border-dashed border-line bg-surface p-4"
>
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">
{m.timeline_undated_section()} · {timeline.undated.length}
</h2>
<ul class="space-y-2">
<!-- The undated bucket is filtered from ALL entries, so it can hold
events as well as letters. Dispatch on kind/type exactly like
@@ -85,6 +90,18 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
{/if}
<style>
/* Establish a stacking context so the spine (z-index: -1) sits behind the
in-flow cards/pills/strips but still in front of the canvas — the line is
always background; the badges, dots and markers ride on top of it.
--spine-x is the single source of truth for the spine's X position; the
year-node and connector dots in YearBand consume it via inheritance so the
markers can never desync from the line. */
.timeline-axis {
--spine-x: 0.5rem;
position: relative;
z-index: 0;
}
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
centered spine the bands' alternating cards sit on either side of. The
spine is decorative — the chronology lives in the <ol> DOM order. */
@@ -93,14 +110,19 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
position: absolute;
top: 0;
bottom: 0;
left: 0.5rem;
left: var(--spine-x);
width: 2px;
background: linear-gradient(var(--palette-mint), var(--palette-navy));
z-index: -1;
/* Three-stop life-thread: mint → navy → slate. Slate lives only as
--c-tag-slate (there is no --palette-slate). REQ-006/013. */
background: linear-gradient(var(--palette-mint), var(--palette-navy), var(--c-tag-slate));
}
@media (min-width: 1024px) {
.timeline-axis {
--spine-x: 50%;
}
.timeline-axis::before {
left: 50%;
transform: translateX(-50%);
}
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import TimelineView from './TimelineView.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
@@ -73,6 +74,19 @@ describe('TimelineView', () => {
expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
});
it('frames the undated section with a dashed border and shows the count (REQ-012)', () => {
const undated = Array.from({ length: 11 }, (_, i) =>
makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: `u-${i}` })
);
render(TimelineView, { timeline: makeTimelineDTO({ undated }) });
const section = document.querySelector('[data-testid="undated-section"]') as HTMLElement;
expect(section).not.toBeNull();
expect(section.classList.contains('border-dashed')).toBe(true);
const h2 = section.querySelector('h2');
expect(h2?.textContent).toContain(m.timeline_undated_section());
expect(h2?.textContent).toContain('11');
});
it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
@@ -226,6 +240,34 @@ describe('TimelineView', () => {
expect(document.body.textContent).toContain('Geburt');
});
it('paints the axis with a three-stop mint→navy→slate gradient (REQ-006)', () => {
// The palette tokens live in layout.css, which component tests don't load,
// so define exactly the three the gradient must reference; an undefined
// fourth token would invalidate the declaration and yield "none".
const root = document.documentElement;
root.style.setProperty('--palette-mint', '#a1dcd8');
root.style.setProperty('--palette-navy', '#012851');
root.style.setProperty('--c-tag-slate', '#607080');
try {
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry()])] })
});
const axis = document.querySelector('.timeline-axis') as HTMLElement;
expect(axis).not.toBeNull();
const before = getComputedStyle(axis, '::before');
const bg = before.backgroundImage;
expect(bg).toContain('gradient');
// three colour stops: mint, navy, slate
expect((bg.match(/rgb/g) ?? []).length).toBe(3);
// the spine is always background: behind the in-flow cards/pills/strips.
expect(before.zIndex).toBe('-1');
} finally {
root.style.removeProperty('--palette-mint');
root.style.removeProperty('--palette-navy');
root.style.removeProperty('--c-tag-slate');
}
});
it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });

View File

@@ -21,6 +21,9 @@ const fromYear = $derived(entry.eventDate ? entry.eventDate.slice(0, 4) : null);
const toYear = $derived(entry.eventDateEnd ? entry.eventDateEnd.slice(0, 4) : null);
const showSpan = $derived(entry.precision === 'RANGE' && fromYear != null && toYear != null);
const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYear : dateLabel);
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
// always trails the subtitle as plain text — never a second pill (REQ-009).
const historical = $derived(m.timeline_layer_historical_suffix());
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
@@ -40,4 +43,7 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
{:else if dateText}
<span class="ml-2 font-sans text-xs text-ink-3">{dateText}</span>
{/if}
<!-- Single trailing "· historisch" register, after the title and any
span pill / date — one render site, consistent separator (REQ-009). -->
<span class="ml-2 font-sans text-xs text-ink-3">· {historical}</span>
</div>

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import WorldBand from './WorldBand.svelte';
import { makeEntry } from './test-factories';
@@ -45,4 +46,31 @@ describe('WorldBand', () => {
expect(document.body.textContent).toContain('Erster Weltkrieg');
expect(document.body.textContent).toContain('1914');
});
it('appends the inline "· historisch" descriptor to a non-RANGE band (#833 REQ-009)', () => {
render(WorldBand, {
entry: historical({ precision: 'APPROX', eventDate: '1923-01-01', eventDateEnd: undefined })
});
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
});
it('shows "· historisch" with a leading separator even when the band has no date (#833 REQ-009)', () => {
render(WorldBand, {
entry: historical({ precision: 'UNKNOWN', eventDate: undefined, eventDateEnd: undefined })
});
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain(`· ${m.timeline_layer_historical_suffix()}`);
});
it('follows the RANGE span pill with inline "· historisch" text, not a second pill (#833 REQ-009)', () => {
render(WorldBand, { entry: historical() });
const pill = document.querySelector('[data-testid="world-range"]');
expect(pill).not.toBeNull();
// The #779 span pill + its Zeitraum aria-label are unchanged.
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
// The descriptor is plain text outside the pill, not a second styled pill.
expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix());
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
});
});

View File

@@ -46,10 +46,13 @@ const rows = $derived.by<Row[]>(() => {
</script>
<section class="py-2">
<h2
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
>
{year.year}
<h2 class="year-heading">
<span data-testid="year-node" class="year-node bg-brand-navy" aria-hidden="true"></span>
<span
data-testid="year-label"
class="year-label rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
>{year.year}</span
>
</h2>
<div class="mt-3 space-y-3">
@@ -62,6 +65,7 @@ const rows = $derived.by<Row[]>(() => {
{/if}
{: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>
<LetterCard entry={row.entry} />
</div>
{:else}
@@ -73,19 +77,75 @@ const rows = $derived.by<Row[]>(() => {
<style>
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
header is a 64px sticky nav). REQ-006. */
header is a 64px sticky nav). #779 REQ-006 / #833 REQ-003. The sticky
element is also the positioning context for the year-node marker. */
.year-heading {
position: sticky;
top: 4rem;
z-index: 1;
}
/* Phone (< 1024px): single left-anchored column, all letters on one side
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
so consecutive cards sit on opposite sides of the spine (REQ-004). */
/* Markers ride on the spine's single source of truth: --spine-x, declared
once on TimelineView's .timeline-axis and inherited here (0.5rem phone /
50% desktop). The 0.5rem fallback only applies when a YearBand is rendered
outside the axis (e.g. component tests). #833 REQ-003/004/005. */
/* Phone (< 1024px): badge sits at the left spine, clearing the node marker.
The badge sits above the node (z-index) so on desktop, where the centered
pill covers the centered node, the white year text is never occluded. */
.year-label {
display: inline-block;
margin-left: 1.75rem;
position: relative;
z-index: 2;
}
/* Navy node marker on the spine. On phone it shows to the left of the badge;
on desktop it sits behind the centered pill, which is itself the
axis interruption. */
.year-node {
position: absolute;
top: 50%;
left: var(--spine-x, 0.5rem);
width: 11px;
height: 11px;
border-radius: 9999px;
transform: translate(-50%, -50%);
z-index: 1;
}
/* Per-letter connector dot (white fill via bg-surface, mint ring) on the spine. */
.letter-row {
position: relative;
padding-left: 1.75rem;
}
.letter-dot {
position: absolute;
top: 0.9rem;
left: var(--spine-x, 0.5rem);
width: 13px;
height: 13px;
border-radius: 9999px;
border: 2.5px solid var(--palette-mint);
transform: translate(-50%, -50%);
z-index: 2;
}
/* Desktop (≥ 1024px): centered axis. The badge centers on the spine, the node
sits at the spine centre, and letters alternate left/right with the
connector dot on the centred spine between card and axis. #833 REQ-003/004/005. */
@media (min-width: 1024px) {
.year-label {
display: block;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
/* .year-node needs no desktop override — it inherits --spine-x: 50% from
the axis. */
.letter-row {
width: 50%;
padding-left: 0;
}
.letter-row[data-side='left'] {
margin-right: auto;
@@ -95,5 +155,15 @@ const rows = $derived.by<Row[]>(() => {
margin-left: auto;
padding-left: 1.75rem;
}
.letter-row[data-side='left'] .letter-dot {
left: auto;
right: 0;
transform: translate(50%, -50%);
}
.letter-row[data-side='right'] .letter-dot {
left: 0;
right: auto;
transform: translate(-50%, -50%);
}
}
</style>

View File

@@ -1,9 +1,13 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import YearBand from './YearBand.svelte';
import { makeEntry, makeYear } from './test-factories';
afterEach(() => cleanup());
afterEach(async () => {
cleanup();
await page.viewport(1280, 720);
});
function manyLetters(year: number, count: number) {
return Array.from({ length: count }, (_, i) =>
@@ -81,4 +85,83 @@ describe('YearBand', () => {
expect(document.body.textContent).toContain('Heirat');
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
});
it('centers the year badge on the axis at desktop (REQ-003)', async () => {
await page.viewport(1440, 900);
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const section = document.querySelector('section') as HTMLElement;
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
const s = section.getBoundingClientRect();
const b = badge.getBoundingClientRect();
const sectionCenter = s.left + s.width / 2;
const badgeCenter = b.left + b.width / 2;
expect(Math.abs(badgeCenter - sectionCenter)).toBeLessThan(8);
});
it('left-aligns the year badge at phone width (REQ-003)', async () => {
await page.viewport(375, 800);
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const section = document.querySelector('section') as HTMLElement;
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
const s = section.getBoundingClientRect();
const b = badge.getBoundingClientRect();
// hugs the left spine — clearly not centered
expect(b.left - s.left).toBeLessThan(s.width / 3);
});
it('keeps the sticky year heading at top:4rem (REQ-003)', () => {
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const h2 = document.querySelector('h2') as HTMLElement;
const cs = getComputedStyle(h2);
expect(cs.position).toBe('sticky');
expect(cs.top).toBe('64px');
});
it('renders a year-badge node marker that clears the badge text on phone (REQ-004)', async () => {
await page.viewport(375, 800);
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
const label = document.querySelector('[data-testid="year-label"]') as HTMLElement;
expect(node).not.toBeNull();
const n = node.getBoundingClientRect();
const l = label.getBoundingClientRect();
expect(n.right).toBeLessThanOrEqual(l.left + 0.5);
// The badge must paint above the node so the centered desktop pill never
// occludes the white year text (regression guard).
expect(Number(getComputedStyle(label).zIndex)).toBeGreaterThan(
Number(getComputedStyle(node).zIndex)
);
});
it('renders one connector dot per letter row, each clearing its card on phone (REQ-005)', async () => {
await page.viewport(375, 800);
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
const dots = document.querySelectorAll('[data-testid="letter-dot"]');
expect(dots).toHaveLength(3);
const row = document.querySelector('.letter-row') as HTMLElement;
const dot = row.querySelector('[data-testid="letter-dot"]') as HTMLElement;
const card = row.querySelector('a') as HTMLElement;
expect(dot.getBoundingClientRect().right).toBeLessThanOrEqual(
card.getBoundingClientRect().left + 0.5
);
});
it('positions the year-node from the inherited --spine-x token (REQ-003/004)', async () => {
await page.viewport(375, 800);
// The spine X lives once on .timeline-axis as --spine-x; the markers must
// track that token, not a hard-coded offset, so they never desync.
document.documentElement.style.setProperty('--spine-x', '3rem');
try {
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
const section = document.querySelector('section') as HTMLElement;
const n = node.getBoundingClientRect();
const s = section.getBoundingClientRect();
const nodeCenter = n.left + n.width / 2;
// --spine-x:3rem = 48px from the band's left edge
expect(Math.abs(nodeCenter - s.left - 48)).toBeLessThan(2);
} finally {
document.documentElement.style.removeProperty('--spine-x');
}
});
});

View File

@@ -1,6 +1,9 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import Sparkline from '$lib/shared/primitives/Sparkline.svelte';
import GlyphLabel from './GlyphLabel.svelte';
import LetterCard from './LetterCard.svelte';
import { monthHistogram } from './timelineDensity';
import type { components } from '$lib/generated/api';
@@ -10,20 +13,27 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Compact density view for a year with many letters (REQ-012): the letter count
* plus a 12-month density sparkline, and a ≥44px keyboard-focusable toggle that
* expands to that year's individual LetterCards.
* expands to that year's individual LetterCards. The count carries a ✉ glyph and
* the sparkline is captioned with its month-density meaning and Jan/Dez endpoint
* labels (REQ-010/011).
*/
let { letters, year }: { letters: TimelineEntryDTO[]; year: number } = $props();
let expanded = $state(false);
const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
// Two endpoint month labels only (not one per bar). Pass the Jan/Dez anchors so
// the locale formatter returns a month (a bare "{year}" returns just the year).
const janLabel = $derived(formatTickLabel(`${year}-01`, getLocale()));
const dezLabel = $derived(formatTickLabel(`${year}-12`, getLocale()));
</script>
<div class="mx-auto max-w-md rounded-sm border border-line bg-surface p-3 shadow-sm">
<div class="flex items-center justify-between gap-3">
<span class="font-sans text-sm font-bold text-brand-navy"
>{m.timeline_letters_count({ count: letters.length })}</span
>
<span class="font-sans text-sm font-bold text-brand-navy">
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
{m.timeline_letters_count({ count: letters.length })}
</span>
<button
type="button"
data-testid="strip-expand"
@@ -42,6 +52,15 @@ const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
class="mt-2"
/>
<!-- Two endpoint month labels + the density caption, beneath the sparkline
(REQ-010/011). 10px is the floor for this micro-axis (the spec's 6px is
below this project's legibility floor for the 60+ transcriber audience). -->
<div class="mt-1 flex items-center justify-between gap-2 font-sans text-[10px] text-ink-3">
<span data-testid="strip-axis-label">{janLabel}</span>
<span>{m.timeline_strip_density_caption()}</span>
<span data-testid="strip-axis-label">{dezLabel}</span>
</div>
{#if expanded}
<ul class="mt-3 space-y-2">
{#each letters as letter (letter.documentId)}

View File

@@ -1,6 +1,9 @@
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 { getLocale } from '$lib/paraglide/runtime.js';
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import YearLetterStrip from './YearLetterStrip.svelte';
import { makeEntry } from './test-factories';
@@ -39,4 +42,32 @@ describe('YearLetterStrip', () => {
await tick();
expect(document.querySelectorAll('a').length).toBe(30);
});
it('prefixes the count with an aria-hidden ✉ + sr-only "Brief" and shows the density caption (REQ-010)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toContain('✉');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label());
expect(document.body.textContent).toContain(m.timeline_strip_density_caption());
});
it('keeps the expand toggle and its "Briefe anzeigen" label alongside the new chrome (REQ-010)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
expect(toggle).not.toBeNull();
expect(toggle.textContent).toContain(m.timeline_strip_expand());
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it('renders exactly two endpoint month-axis labels (Jan/Dez {year}) at ≥10px (REQ-011)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const labels = document.querySelectorAll('[data-testid="strip-axis-label"]');
expect(labels).toHaveLength(2);
expect(labels[0].textContent).toContain(formatTickLabel('1915-01', getLocale()));
expect(labels[1].textContent).toContain(formatTickLabel('1915-12', getLocale()));
for (const label of labels) {
expect(parseFloat(getComputedStyle(label).fontSize)).toBeGreaterThanOrEqual(10);
}
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { timelineMeta } from './timelineMeta';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
const letter = (id: string) => makeEntry({ kind: 'LETTER', documentId: id });
const event = (title: string) =>
makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title,
senderName: '',
receiverName: '',
documentId: undefined
});
describe('timelineMeta', () => {
it('counts letters and events across year bands and the undated bucket (REQ-002)', () => {
const dto = makeTimelineDTO({
years: [
makeYear(1909, [letter('a'), event('Geburt'), letter('b')]),
makeYear(1924, [event('Tod')])
],
undated: [letter('c'), event('Heirat')]
});
const meta = timelineMeta(dto);
expect(meta.letterCount).toBe(3);
expect(meta.eventCount).toBe(3);
});
it('reads the range from the first and last year band (REQ-002)', () => {
const dto = makeTimelineDTO({
years: [makeYear(1909, [letter('a')]), makeYear(1924, [letter('b')])]
});
const meta = timelineMeta(dto);
expect(meta.firstYear).toBe(1909);
expect(meta.lastYear).toBe(1924);
});
it('has a null range when there are no year bands, but still counts undated (REQ-002)', () => {
const dto = makeTimelineDTO({ undated: [letter('a')] });
const meta = timelineMeta(dto);
expect(meta.firstYear).toBeNull();
expect(meta.lastYear).toBeNull();
expect(meta.letterCount).toBe(1);
});
it('reports zero counts and a null range for an empty timeline (REQ-002)', () => {
expect(timelineMeta(makeTimelineDTO())).toEqual({
firstYear: null,
lastYear: null,
letterCount: 0,
eventCount: 0
});
});
});

View File

@@ -0,0 +1,38 @@
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
export interface TimelineMeta {
/** First year band's year, or `null` when there are no bands. */
firstYear: number | null;
/** Last year band's year, or `null` when there are no bands. */
lastYear: number | null;
/** Every `LETTER` entry across all year bands plus the undated bucket. */
letterCount: number;
/** Every `EVENT` entry (derived, curated, and historical) across all bands plus undated. */
eventCount: number;
}
/**
* Derives the header meta-line figures from a loaded `TimelineDTO` (REQ-002):
* the year range (first/last band) and the letter/event totals across every
* year band plus the undated bucket. Pure and the single place these counts
* live — the route renders them; `TimelineView` never recomputes them.
*/
export function timelineMeta(timeline: TimelineDTO): TimelineMeta {
const years = timeline.years;
let letterCount = 0;
let eventCount = 0;
const tally = (e: TimelineDTO['undated'][number]) => {
if (e.kind === 'LETTER') letterCount += 1;
else if (e.kind === 'EVENT') eventCount += 1;
};
for (const y of years) for (const e of y.entries) tally(e);
for (const e of timeline.undated) tally(e);
return {
firstYear: years.length ? years[0].year : null,
lastYear: years.length ? years[years.length - 1].year : null,
letterCount,
eventCount
};
}

View File

@@ -1,9 +1,42 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte';
import { timelineMeta } from '$lib/timeline/timelineMeta';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const meta = $derived(timelineMeta(data.timeline));
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
// Compose the sub-line from segments joined by " · " so the range drops out
// cleanly when there are no year bands; the whole line is absent when the
// timeline is empty (REQ-002). Counts come from the route alone, never from
// TimelineView.
const metaLine = $derived.by(() => {
const segments: string[] = [];
if (meta.firstYear !== null && meta.lastYear !== null) {
segments.push(`${meta.firstYear}${meta.lastYear}`);
}
// A zero-count segment ("0 Briefe") reads as a data error — drop it; a count
// of one takes the singular key ("1 Brief"), per the project plural convention.
if (meta.letterCount > 0) {
segments.push(
meta.letterCount === 1
? m.timeline_letters_count_singular()
: m.timeline_letters_count({ count: meta.letterCount })
);
}
if (meta.eventCount > 0) {
segments.push(
meta.eventCount === 1
? m.timeline_events_count_singular()
: m.timeline_events_count({ count: meta.eventCount })
);
}
segments.push(m.timeline_grouping_date());
return segments.join(' · ');
});
</script>
<svelte:head>
@@ -11,6 +44,14 @@ let { data }: { data: PageData } = $props();
</svelte:head>
<div class="mx-auto max-w-5xl px-4 py-8">
<h1 class="mb-8 font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
<TimelineView timeline={data.timeline} />
<!-- The .tl-canvas sheet: a padded canvas surface for the timeline. The outer
border is intentionally omitted (the page is already bg-canvas), per the
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
{#if hasContent}
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
{/if}
<TimelineView timeline={data.timeline} />
</div>
</div>

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import Page from './+page.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
afterEach(() => cleanup());
const event = (title: string) =>
makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title,
senderName: '',
receiverName: '',
documentId: undefined
});
// The route's PageData merges the layout's auth fields with the page load's
// timeline; the component only reads `timeline`, but the full shape keeps the
// test type-clean.
const pageData = (timeline: ReturnType<typeof makeTimelineDTO>) => ({
user: undefined,
canWrite: false,
canAnnotate: false,
canBlogWrite: false,
timeline
});
describe('/zeitstrahl page', () => {
it('wraps the timeline in a padded canvas surface, without an outer border (REQ-001)', () => {
render(Page, {
data: pageData(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] }))
});
const canvas = document.querySelector('[data-testid="timeline-canvas"]');
expect(canvas).not.toBeNull();
expect(canvas?.classList.contains('bg-canvas')).toBe(true);
// The outer border was dropped in review (the page is already bg-canvas).
expect(canvas?.classList.contains('border')).toBe(false);
// the timeline renders inside the surface
expect(canvas?.querySelector('ol')).not.toBeNull();
});
it('renders the meta sub-line with range, counts, and grouping (REQ-002)', () => {
const dto = makeTimelineDTO({
years: [
makeYear(1909, [
makeEntry({ documentId: 'a' }),
makeEntry({ documentId: 'b' }),
event('Geburt')
]),
makeYear(1924, [makeEntry({ documentId: 'c' }), event('Tod')])
]
});
render(Page, { data: pageData(dto) });
const sub = document.querySelector('[data-testid="timeline-meta"]');
expect(sub?.textContent).toContain('19091924');
expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 }));
expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 }));
expect(sub?.textContent).toContain(m.timeline_grouping_date());
});
it('omits the range segment when there are no year bands (REQ-002)', () => {
render(Page, {
data: pageData(makeTimelineDTO({ undated: [makeEntry({ documentId: 'u1' })] }))
});
const sub = document.querySelector('[data-testid="timeline-meta"]');
expect(sub).not.toBeNull();
expect(sub?.textContent).not.toContain('');
expect(sub?.textContent).toContain(m.timeline_letters_count_singular());
});
it('omits the entire sub-line for an empty timeline (REQ-002)', () => {
render(Page, { data: pageData(makeTimelineDTO()) });
expect(document.querySelector('[data-testid="timeline-meta"]')).toBeNull();
});
it('drops the letters segment instead of showing "0 Briefe" (REQ-002)', () => {
render(Page, {
data: pageData(makeTimelineDTO({ years: [makeYear(1914, [event('Geburt')])] }))
});
const sub = document.querySelector('[data-testid="timeline-meta"]');
expect(sub).not.toBeNull();
expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 }));
expect(sub?.textContent).toContain(m.timeline_grouping_date());
});
it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => {
render(Page, {
data: pageData(makeTimelineDTO({ undated: [makeEntry({ documentId: 'u1' })] }))
});
const sub = document.querySelector('[data-testid="timeline-meta"]');
expect(sub).not.toBeNull();
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 0 }));
});
it('uses singular count labels for exactly one letter and one event (REQ-002)', () => {
render(Page, {
data: pageData(
makeTimelineDTO({
years: [makeYear(1914, [makeEntry({ documentId: 'a' }), event('Geburt')])]
})
)
});
const sub = document.querySelector('[data-testid="timeline-meta"]');
expect(sub?.textContent).toContain(m.timeline_letters_count_singular());
expect(sub?.textContent).toContain(m.timeline_events_count_singular());
// never the "1 Briefe"/"1 Ereignisse" plural forms for a count of one
expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 1 }));
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
});
});