Compare commits

...

10 Commits

Author SHA1 Message Date
Marcel
a92644d69b test(briefwechsel): visual spec seeds bilateral pair and asserts row structure
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m40s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 3m1s
CI / Unit & Component Tests (push) Failing after 2m36s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m53s
Extends the seeding pattern from the a11y spec: beforeAll creates two
persons + one document so the page renders the row layout. The
structural test now asserts the ConversationThumbnail tile AND the
DistributionBar are present — a regression that drops to the hero
or breaks the row wiring fails here instead of silently passing a
hero-state check.

Snapshot block stays gated on VISUAL=1 (baselines captured during
review against a seeded backend) so the structural coverage ships
immediately and the pixel-diff coverage ships once baselines land.

Refs #305
Fixes @saraholt blocker 2 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:46:25 +02:00
Marcel
d1a69e0b43 test(briefwechsel): a11y spec seeds bilateral pair and axes the row layout
The previous version navigated to /briefwechsel with no params, which
renders the hero state — axe-core scanned the hero, not the new
ThumbnailRow / ConversationThumbnail / DistributionBar. This commit
seeds two persons + one document via the API in beforeAll, then
drives the URL with ?senderId=X&receiverId=Y so each of the
36 test runs (3 viewports × 2 themes × 2 assertions) actually scans
the intended DOM. Also asserts that conv-person-bar is visible first,
so a regression that drops the page back to hero fails explicitly
rather than silently passing an empty sweep.

Refs #305
Fixes @saraholt blocker 1 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:44:58 +02:00
Marcel
3b3b551d84 refactor(briefwechsel): extract TagChipList from ThumbnailRow
Lifts the three-chip-plus-"+N" tag row out of ThumbnailRow into a
standalone TagChipList component so the chip cap + overflow policy
lives in one place and can be reused on other surfaces (document
detail header is a candidate). ThumbnailRow drops from 110 to ~90
lines and no longer owns tag-slicing logic — it just asks for the
list with max=3.

Behavior is byte-identical: same data-testid, same max cap, same
"+N" overflow indicator. All ThumbnailRow row-level tag tests
continue to pass against the new composition.

Refs #305
Fixes @felixbrandt suggestion 1 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:43:04 +02:00
Marcel
78caac8d1a refactor(thumbnails): pack key + aspect + pageCount into ThumbnailResult
persistThumbnailMetadata was a four-arg method signature that mixed
three conceptually related values. Wrapping them in a private
ThumbnailResult record drops the signature to (Document, result),
mirrors the existing SourcePreview record one step earlier in the
pipeline, and keeps generate() reading as a narrative of small
named outputs rather than positional arguments.

Refs #305
Fixes @felixbrandt suggestion 2 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:39:04 +02:00
Marcel
514f0174e3 refactor(briefwechsel): ThumbnailRow captures now at prop binding
Defaults `now` in $props() destructure so each row instance freezes
its reference time at mount, instead of calling new Date() inside
the $derived every reactivity tick. No behavioural change — the
date math is stable across re-renders for a given row — but drops
the nullish-coalesce dance and is cleaner under Storybook-style
testing where a deterministic `now` is injected.

Refs #305
Fixes @felixbrandt suggestion 3 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:36:52 +02:00
Marcel
54ef1643a4 test(briefwechsel): lock future-date relative-year hiding at the row layer
relativeYearsDe already returns "" for future dates (covered in its
own spec), but the integration wiring inside ThumbnailRow was
untested. Adds a regression that a doc with documentDate in the
future produces no "vor N Jahren" or "vor weniger als 1 Jahr" chip.

Refs #305
Fixes @saraholt concern 5 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:35:25 +02:00
Marcel
3b83141ddf test(briefwechsel): makePerson factory + per-row tile assertion
Consolidates the hansPerson / annaPerson fixture into a makePerson()
factory matching the makeDoc convention, adds an assertion that
the bilateral list renders one ConversationThumbnail tile per
document (catches a broken {#each} keying wired around the
DistributionBar), and decouples the DistributionBar aria-label
assertion from the German locale now that i18n lands via Paraglide.

Refs #305
Fixes @saraholt concerns 3 + 4 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:34:00 +02:00
Marcel
0a3e5d6260 feat(briefwechsel): ConversationThumbnail page badge legible at small sizes
Bumps the multi-page badge from text-xs (12px) / px-1.5 py-0.5 to
text-sm (14px) / px-2 py-1. Meets senior-legibility on a 320px phone
without crowding the 120-wide tile — the badge stays tucked in the
top-right corner.

Refs #305
Fixes @leonievoss senior-accessibility concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:30:19 +02:00
Marcel
858c9f0564 i18n(briefwechsel): DistributionBar reads text + aria-label via Paraglide
Drops the hardcoded German strings ("Briefverteilung in diesem Zeitraum",
"{n} von {name}") and routes every visible + assistive-tech string
through dist_bar_aria and dist_bar_segment message keys. An English
or Spanish user now sees "from" / "de" instead of "von" both on
screen and in the aria-label their screen reader announces.

Refs #305
Fixes @leonievoss i18n concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:28:32 +02:00
Marcel
2d7fac6bb3 feat(briefwechsel): ThumbnailRow aria-label leads with Gesendet/Empfangen
Without this prefix, a color-blind user or screen-reader user has no
indication of correspondence direction — the colored left border is
information but not announced, and the arrow glyphs were removed in
the earlier layout pass. Prepending "Gesendet:" or "Empfangen:" to
the aria-label gives assistive-tech users the direction first so the
row identity is unambiguous even without color perception.

Refs #305
Fixes @leonievoss WCAG 1.4.1 concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:23:46 +02:00
15 changed files with 275 additions and 69 deletions

View File

@@ -99,8 +99,9 @@ public class ThumbnailService {
String thumbnailKey = thumbnailKeyFor(doc.getId());
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
ThumbnailAspect aspect = aspectOf(preview.image());
return persistThumbnailMetadata(doc, thumbnailKey, aspect, preview.pageCount());
ThumbnailResult result = new ThumbnailResult(
thumbnailKey, aspectOf(preview.image()), preview.pageCount());
return persistThumbnailMetadata(doc, result);
}
private static ThumbnailAspect aspectOf(BufferedImage source) {
@@ -112,6 +113,10 @@ public class ThumbnailService {
// 1 for image uploads; for PDFs it comes straight from PDDocument.
private record SourcePreview(BufferedImage image, int pageCount) {}
// Everything the generate pipeline has already committed to storage and
// now wants stamped onto the Document entity in a single save call.
private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}
private static String thumbnailKeyFor(UUID documentId) {
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
}
@@ -156,13 +161,12 @@ public class ThumbnailService {
}
}
private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey,
ThumbnailAspect aspect, int pageCount) {
private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
try {
doc.setThumbnailKey(thumbnailKey);
doc.setThumbnailKey(result.key());
doc.setThumbnailGeneratedAt(LocalDateTime.now());
doc.setThumbnailAspect(aspect);
doc.setPageCount(pageCount);
doc.setThumbnailAspect(result.aspect());
doc.setPageCount(result.pageCount());
documentRepository.save(doc);
return Outcome.SUCCESS;
} catch (Exception e) {
@@ -172,7 +176,7 @@ public class ThumbnailService {
// overwrite it cleanly. Logging distinctly so an operator tracking
// backfill totals can spot the database-side issue.
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
doc.getId(), thumbnailKey, e.getMessage());
doc.getId(), result.key(), e.getMessage());
return Outcome.FAILED;
}
}

View File

@@ -2,9 +2,9 @@ import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
// Accessibility coverage specifically for the briefwechsel thumbnail-row
// layout. Runs axe-core (wcag2a + wcag2aa) across three viewports and two
// color schemes so color-contrast regressions on the meta text or the
// distribution bar are caught independently of the site-wide sweep.
// layout. Seeds two persons + a bilateral document via the API so the page
// reaches the results state (not the hero), then runs axe-core
// (wcag2a + wcag2aa) across three viewports and two color schemes.
const VIEWPORTS = [
{ name: 'mobile', width: 375, height: 812 },
@@ -14,15 +14,49 @@ const VIEWPORTS = [
const THEMES = ['light', 'dark'] as const;
let senderId: string;
let receiverId: string;
test.describe('Accessibility — /briefwechsel row layout', () => {
test.beforeAll(async ({ request }) => {
const timestamp = Date.now();
const senderRes = await request.post('/api/persons', {
data: { firstName: 'A11y', lastName: `Sender-${timestamp}` }
});
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
senderId = (await senderRes.json()).id;
const receiverRes = await request.post('/api/persons', {
data: { firstName: 'A11y', lastName: `Receiver-${timestamp}` }
});
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
receiverId = (await receiverRes.json()).id;
const docRes = await request.post('/api/documents', {
multipart: {
title: 'A11y Test Brief',
documentDate: '1950-06-15',
senderId,
receiverIds: receiverId
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
});
for (const vp of VIEWPORTS) {
for (const theme of THEMES) {
test(`${vp.name} / ${theme} has no wcag2a/wcag2aa violations`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.emulateMedia({ colorScheme: theme });
await page.goto('/briefwechsel');
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(senderId)}&receiverId=${encodeURIComponent(receiverId)}`
);
await page.waitForSelector('[data-hydrated]');
// Assert we actually reached the row layout, not the hero — otherwise
// the axe sweep silently scans the wrong DOM.
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.include('main')

View File

@@ -2,23 +2,69 @@ import { test, expect } from '@playwright/test';
// Visual + structural coverage for the new briefwechsel row layout.
//
// Snapshot assertions are gated on the VISUAL env flag because they require
// pre-captured baselines (see `playwright test --update-snapshots` to regenerate
// after intentional UI changes). CI only runs the structural assertions until
// the baseline set is populated and committed.
// Seeds a bilateral correspondence pair via the API (beforeAll) so the page
// reaches the row state. The structural test asserts that a
// ConversationThumbnail tile AND the DistributionBar render — regressions
// that silently drop to the hero or break the {#each} wiring fail here.
//
// Snapshot assertions are gated on the VISUAL env flag because they need
// pre-captured baselines (see `playwright test --update-snapshots` to
// regenerate after intentional UI changes). CI can opt in via VISUAL=1.
const VISUAL = process.env.VISUAL === '1';
let senderId: string;
let receiverId: string;
test.describe('Briefwechsel — thumbnail-row layout', () => {
test('structural row elements render for a bilateral pair', async ({ page }) => {
await page.goto('/briefwechsel');
// Hero is visible until a person is selected.
await expect(page.getByTestId('conv-hero')).toBeVisible();
test.beforeAll(async ({ request }) => {
const timestamp = Date.now();
const senderRes = await request.post('/api/persons', {
data: { firstName: 'Visual', lastName: `Sender-${timestamp}` }
});
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
senderId = (await senderRes.json()).id;
const receiverRes = await request.post('/api/persons', {
data: { firstName: 'Visual', lastName: `Receiver-${timestamp}` }
});
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
receiverId = (await receiverRes.json()).id;
const docRes = await request.post('/api/documents', {
multipart: {
title: 'Visual Test Brief',
documentDate: '1950-06-15',
senderId,
receiverIds: receiverId
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
});
// Visual regression — one snapshot per (viewport × theme). Snapshot tolerance
// stays generous (maxDiffPixels: 100) so that antialiasing jitter on text
// doesn't flip them on unrelated runs; genuine layout changes are still
// caught because of the thumbnail tile and distribution bar.
async function openBilateral(page: import('@playwright/test').Page) {
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(senderId)}&receiverId=${encodeURIComponent(receiverId)}`
);
await page.waitForSelector('[data-hydrated]');
}
test('renders a ConversationThumbnail tile and the DistributionBar', async ({ page }) => {
await openBilateral(page);
// Tile appears for the seeded document
await expect(page.locator('[data-testid="conv-thumb-tile"]').first()).toBeVisible();
// DistributionBar is present (role=img with a descriptive aria-label)
const bar = page.locator('[role="img"]');
await expect(bar).toBeVisible();
const label = (await bar.getAttribute('aria-label')) ?? '';
expect(label.length).toBeGreaterThan(0);
});
// Visual regression — one snapshot per (viewport × theme). Tolerance stays
// generous (maxDiffPixels: 100) so antialiasing jitter doesn't flip them on
// unrelated runs; genuine layout changes are still caught because the
// thumbnail tile and distribution bar dominate the frame.
test.describe('snapshots', () => {
test.skip(!VISUAL, 'VISUAL=1 required to compare baselines');
@@ -31,7 +77,7 @@ test.describe('Briefwechsel — thumbnail-row layout', () => {
test(`${viewport.name} / ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.emulateMedia({ colorScheme: theme });
await page.goto('/briefwechsel');
await openBilateral(page);
await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
maxDiffPixels: 100,
fullPage: true

View File

@@ -165,6 +165,8 @@
"conv_hero_divider": "oder",
"conv_empty_recent_label": "Zuletzt geöffnet",
"conv_no_party": "—",
"dist_bar_segment": "{count} von {name}",
"dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",
"admin_tab_groups": "Gruppen",

View File

@@ -165,6 +165,8 @@
"conv_hero_divider": "or",
"conv_empty_recent_label": "Recently opened",
"conv_no_party": "—",
"dist_bar_segment": "{count} from {name}",
"dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",
"admin_tab_groups": "Groups",

View File

@@ -165,6 +165,8 @@
"conv_hero_divider": "o",
"conv_empty_recent_label": "Recientemente abiertos",
"conv_no_party": "—",
"dist_bar_segment": "{count} de {name}",
"dist_bar_aria": "Distribución de cartas en este período: {outCount} de {senderName}, {inCount} de {receiverName}",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",
"admin_tab_groups": "Grupos",

View File

@@ -41,7 +41,7 @@ const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[
{#if pageCount > 1}
<span
data-testid="conv-thumb-page-badge"
class="absolute top-1 right-1 rounded-full bg-primary/90 px-1.5 py-0.5 text-xs leading-none font-bold text-surface"
class="absolute top-1 right-1 rounded-full bg-primary/90 px-2 py-1 text-sm leading-none font-bold text-surface"
>{pageCount}</span
>
{/if}

View File

@@ -78,6 +78,9 @@ describe('ConversationThumbnail', () => {
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]') as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain('4');
// Senior-readable size: text-sm (14px) rather than text-xs (12px) on a
// small tile avoids marginal legibility on a 320px phone.
expect(badge.className).toContain('text-sm');
});
it('hides the page badge when pageCount is 1 or missing', () => {

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
outCount: number;
inCount: number;
@@ -12,16 +14,20 @@ const total = $derived(outCount + inCount);
const outPct = $derived(total > 0 ? (outCount / total) * 100 : 0);
const shortSenderName = $derived(senderName.split(' ')[0] ?? senderName);
const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
const ariaLabel = $derived(m.dist_bar_aria({ outCount, senderName, inCount, receiverName }));
const outSegmentText = $derived(m.dist_bar_segment({ count: outCount, name: shortSenderName }));
const inSegmentText = $derived(m.dist_bar_segment({ count: inCount, name: shortReceiverName }));
</script>
<div
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
role="img"
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}"
aria-label={ariaLabel}
>
<div class="flex justify-between text-sm font-bold">
<span class="inline-flex items-center gap-1 text-primary"
>{outCount} von {shortSenderName}
>{outSegmentText}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
@@ -30,7 +36,7 @@ const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
/></span
>
<span class="inline-flex items-center gap-1 text-accent"
>{inCount} von {shortReceiverName}
>{inSegmentText}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
alt=""

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 DistributionBar from './DistributionBar.svelte';
@@ -8,7 +9,7 @@ afterEach(() => {
});
describe('DistributionBar', () => {
it('renders both counts and short names, and the two-tone fill bar', async () => {
it('renders the Paraglide aria-label and visible segments', async () => {
render(DistributionBar, {
outCount: 3,
inCount: 7,
@@ -18,11 +19,22 @@ describe('DistributionBar', () => {
const container = document.querySelector('[role="img"]') as HTMLElement;
expect(container).toBeTruthy();
expect(container.getAttribute('aria-label')).toContain('3 von Hans Müller');
expect(container.getAttribute('aria-label')).toContain('7 von Anna Schmidt');
expect(container.textContent).toContain('3 von Hans');
expect(container.textContent).toContain('7 von Anna');
// The aria-label must come from Paraglide, not a hardcoded German string,
// so the EN / ES users aren't served "Briefverteilung in diesem Zeitraum".
const expectedAria = m.dist_bar_aria({
outCount: 3,
senderName: 'Hans Müller',
inCount: 7,
receiverName: 'Anna Schmidt'
});
expect(container.getAttribute('aria-label')).toBe(expectedAria);
// The visible "{count} from/von {name}" spans must also come from Paraglide.
const outText = m.dist_bar_segment({ count: 3, name: 'Hans' });
const inText = m.dist_bar_segment({ count: 7, name: 'Anna' });
expect(container.textContent).toContain(outText);
expect(container.textContent).toContain(inText);
// 3/10 → 30% / 70% split on the two segments
const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]');
@@ -40,7 +52,8 @@ describe('DistributionBar', () => {
});
const container = document.querySelector('[role="img"]') as HTMLElement;
expect(container.textContent).toContain('1 von SingleWord');
const expected = m.dist_bar_segment({ count: 1, name: 'SingleWord' });
expect(container.textContent).toContain(expected);
});
it('renders a zero-percent left segment when outCount is zero', async () => {

View File

@@ -0,0 +1,23 @@
<script lang="ts">
type Tag = { id: string; name: string };
let { tags, max }: { tags: Tag[]; max: number } = $props();
const displayedTags = $derived(tags.slice(0, max));
const hiddenTagCount = $derived(Math.max(0, tags.length - max));
</script>
{#if tags.length > 0}
<div class="flex flex-wrap items-center gap-1 pt-0.5">
{#each displayedTags as tag (tag.id)}
<span
data-testid="thumb-row-tag"
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
>{tag.name}</span
>
{/each}
{#if hiddenTagCount > 0}
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TagChipList from './TagChipList.svelte';
afterEach(() => {
cleanup();
});
const makeTags = (n: number) =>
Array.from({ length: n }, (_, i) => ({ id: `t${i}`, name: `Tag${i}` }));
describe('TagChipList', () => {
it('renders all tags as chips when under the cap', () => {
render(TagChipList, { tags: makeTags(2), max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(2);
expect(document.body.textContent).not.toMatch(/\+/);
});
it('caps visible chips at max and renders +N for the remainder', () => {
render(TagChipList, { tags: makeTags(5), max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(3);
expect(document.body.textContent).toMatch(/\+2/);
});
it('renders nothing when tags is empty', () => {
render(TagChipList, { tags: [], max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(0);
expect(document.body.textContent).not.toMatch(/\+/);
});
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte';
import TagChipList from '$lib/components/TagChipList.svelte';
import { formatDate } from '$lib/utils/date';
import { relativeYearsDe } from '$lib/relativeTime';
@@ -27,7 +28,7 @@ let {
doc,
isOut,
showOtherParty,
now
now = new Date()
}: {
doc: Doc;
isOut: boolean;
@@ -36,8 +37,6 @@ let {
} = $props();
const title = $derived(doc.title || doc.originalFilename);
const displayedTags = $derived((doc.tags ?? []).slice(0, 3));
const hiddenTagCount = $derived(Math.max(0, (doc.tags ?? []).length - 3));
const otherPartyName = $derived(
showOtherParty
? isOut
@@ -46,12 +45,11 @@ const otherPartyName = $derived(
: ''
);
const relativeYearLabel = $derived(
doc.documentDate
? relativeYearsDe(new Date(doc.documentDate + 'T12:00:00'), now ?? new Date())
: ''
doc.documentDate ? relativeYearsDe(new Date(doc.documentDate + 'T12:00:00'), now) : ''
);
const directionLabel = $derived(isOut ? 'Gesendet' : 'Empfangen');
const ariaLabel = $derived(
`${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
`${directionLabel}: ${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
);
</script>
@@ -92,19 +90,6 @@ const ariaLabel = $derived(
{/if}
</div>
{#if displayedTags.length > 0}
<div class="flex flex-wrap items-center gap-1 pt-0.5">
{#each displayedTags as tag (tag.id)}
<span
data-testid="thumb-row-tag"
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
>{tag.name}</span
>
{/each}
{#if hiddenTagCount > 0}
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
{/if}
</div>
{/if}
<TagChipList tags={doc.tags ?? []} max={3} />
</div>
</a>

View File

@@ -104,6 +104,20 @@ describe('ThumbnailRow', () => {
expect(document.body.textContent).toContain('vor 76 Jahren');
});
it('hides the relative-year label when documentDate is in the future', () => {
// relativeYearsDe returns "" for future/invalid dates; the row must not
// then render an empty chip or print "vor 0 Jahren".
render(ThumbnailRow, {
doc: { ...baseDoc, documentDate: '2030-01-01' },
isOut: true,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
expect(document.body.textContent).not.toMatch(/vor \d+ Jahr/);
expect(document.body.textContent).not.toMatch(/vor weniger/);
});
it('sets border-l class based on isOut', () => {
const { unmount } = render(ThumbnailRow, {
doc: baseDoc,
@@ -127,7 +141,7 @@ describe('ThumbnailRow', () => {
expect(link.className).toContain('border-l-accent');
});
it('exposes a descriptive aria-label combining title and date', () => {
it('exposes a descriptive aria-label combining direction, title, and date', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
@@ -137,10 +151,23 @@ describe('ThumbnailRow', () => {
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
const label = link.getAttribute('aria-label') ?? '';
expect(label).toMatch(/^Gesendet:/);
expect(label).toContain('Liebe Anna');
expect(label).toMatch(/1950/);
});
it('aria-label begins with "Empfangen:" for incoming letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false,
now: new Date('2026-06-01T00:00:00Z')
});
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.getAttribute('aria-label') ?? '').toMatch(/^Empfangen:/);
});
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
render(ThumbnailRow, {
doc: {

View File

@@ -30,20 +30,22 @@ const withPersons = {
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
};
const hansPerson = {
const makePerson = (overrides: Record<string, unknown> = {}) => ({
id: 'p1',
firstName: 'Hans',
lastName: 'Müller',
personType: 'PERSON' as const,
displayName: 'Hans Müller'
};
const annaPerson = {
displayName: 'Hans Müller',
...overrides
});
const hansPerson = makePerson();
const annaPerson = makePerson({
id: 'p2',
firstName: 'Anna',
lastName: 'Schmidt',
personType: 'PERSON' as const,
displayName: 'Anna Schmidt'
};
});
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
@@ -54,8 +56,15 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
location: 'Berlin',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
sender: hansPerson,
receivers: [annaPerson],
sender: makePerson(),
receivers: [
makePerson({
id: 'p2',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt'
})
],
tags: [],
transcription: undefined,
filePath: undefined,
@@ -229,17 +238,33 @@ describe('Briefwechsel page distribution bar', () => {
]
};
render(Page, { data });
const bar = document.querySelector('[role="img"][aria-label*="Briefverteilung"]');
const bar = document.querySelector('[role="img"]');
expect(bar).not.toBeNull();
expect(bar!.getAttribute('aria-label')).toContain('1 von Hans Müller');
expect(bar!.getAttribute('aria-label')).toContain('2 von Anna Schmidt');
const label = bar!.getAttribute('aria-label') ?? '';
expect(label).toContain('Hans Müller');
expect(label).toContain('Anna Schmidt');
expect(label).toMatch(/\b1\b/);
expect(label).toMatch(/\b2\b/);
});
it('does not render the DistributionBar in single-person mode', async () => {
render(Page, { data: { ...withSender, documents: [makeDoc()] } });
const bar = document.querySelector('[role="img"][aria-label*="Briefverteilung"]');
const bar = document.querySelector('[role="img"]');
expect(bar).toBeNull();
});
it('renders a ConversationThumbnail tile for each document in the list', async () => {
// A broken `{#each}` wiring in ConversationTimeline would silently stop
// rendering rows while the DistributionBar above it kept working. Assert
// the per-row tile so that class of regression is caught.
const data = {
...withPersons,
documents: [makeDoc({ id: 'd-a' }), makeDoc({ id: 'd-b' }), makeDoc({ id: 'd-c' })]
};
render(Page, { data });
const tiles = document.querySelectorAll('[data-testid="conv-thumb-tile"]');
expect(tiles).toHaveLength(3);
});
});
// ─── Year dividers ────────────────────────────────────────────────────────────