Compare commits
10 Commits
ce1d118882
...
f74fe16747
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f74fe16747 | ||
|
|
63c0395151 | ||
|
|
22a4e49794 | ||
|
|
c004c3bc6f | ||
|
|
80728200c6 | ||
|
|
dc60d27f20 | ||
|
|
15d9bb1b78 | ||
|
|
9b5c1f64ba | ||
|
|
10be13e6cd | ||
|
|
bc337fb445 |
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# ADR-005: thumbnailAspect + pageCount alongside the thumbnail
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #305 rebalances the /briefwechsel correspondence list into PDF-thumbnail rows. Two pieces of metadata are needed at row-render time:
|
||||
|
||||
- **Aspect ratio** — postcards are landscape (7:5), letters are portrait (5:7). Forcing landscape scans into a portrait tile crops away the signature; forcing portrait scans into a landscape tile wastes horizontal real estate.
|
||||
- **Page count** — multi-page letters should show a "N" badge on their thumbnail so the reader can tell a single-page note from a seven-page letter without clicking in.
|
||||
|
||||
Both values are cheap to derive at the point the thumbnail is generated (the source image is already decoded; the PDF is already loaded) and impossible to derive cheaply later (requires re-reading the S3 object).
|
||||
|
||||
## Decision
|
||||
|
||||
Persist both values as columns on `documents` and populate them inside `ThumbnailService.generate()` — the same code path that writes the JPEG to S3 and stamps `thumbnail_generated_at`.
|
||||
|
||||
- `thumbnail_aspect VARCHAR(16)` mapped to a Java enum `ThumbnailAspect` with two values: `PORTRAIT`, `LANDSCAPE`.
|
||||
- `page_count INTEGER` — `PDDocument.getNumberOfPages()` for PDFs, `1` for image uploads.
|
||||
- Aspect threshold is `source.width / source.height > 1.1` → `LANDSCAPE`; everything else (including near-square A4 scans at ratio ≈ 1.0) stays `PORTRAIT`. The 1.1 margin keeps borderline scans from flipping across the threshold on a rounding error.
|
||||
- Both columns are nullable and remain `null` for historical documents until the existing `/api/admin/generate-thumbnails` backfill rerun populates them.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Derive aspect client-side after image load | First-paint would have all tiles in portrait, then reshuffle into landscape when the JPEG decodes — a visible jank on slow networks. The backend already has the dimensions; client-side recomputation is a waste. |
|
||||
| Store full `width` / `height` columns | Not needed anywhere — consumers want the categorical answer. If a future feature needs exact dimensions, they can be added later without migrating existing rows. |
|
||||
| A separate `thumbnail_metadata` table | Two scalar nullable columns aren't worth a join. See ADR-004 — thumbnails are modeled as a cross-cutting aspect of `Document`, not a sub-domain. |
|
||||
| Derive page count from the existing PDF at render time on the frontend | Duplicates work already done on the backend and requires a separate byte-range fetch of the PDF header. Frontend already gets `pageCount` "for free" via the Document response. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- `ConversationThumbnail.svelte` picks the tile dimensions from `thumbnailAspect` directly — no async measurement, no layout shift.
|
||||
- `ThumbnailRow` reads `pageCount` synchronously for the badge. Multi-page letters are distinguishable at first paint.
|
||||
- Backfill runs the same migration path for every old document — re-executing generates the aspect + pageCount columns along with the JPEG, so operators don't have a second admin button to click.
|
||||
|
||||
**Harder:**
|
||||
- Both columns are `null` for every document until the backfill runs on a given instance. Frontend components guard with `?? 'PORTRAIT'` / `?? 1` so the UI stays sensible during the rollout window. The backfill is idempotent and cheap (reuses existing S3 object), so re-running it is the simplest recovery path.
|
||||
- The aspect threshold is a single constant in Java. A future need to tune per-type (e.g. postcards vs photos) means a code change, not a configuration change — acceptable for a single-operator archive.
|
||||
|
||||
### Ordering inside `ThumbnailService.generate()`
|
||||
|
||||
Aspect computation happens AFTER the JPEG upload succeeds but BEFORE the entity save — if the save throws, the columns rewind with it. Page count is captured while the `PDDocument` is still open; the `SourcePreview` record carries both the rendered first-page image and the page count back to the top of the pipeline so the PDF isn't reopened later.
|
||||
|
||||
## Future Direction
|
||||
|
||||
- If a postcard-specific "photo" chip is ever reintroduced, reuse `thumbnailAspect === 'LANDSCAPE' && pageCount === 1` rather than adding a new `kind` column.
|
||||
- If multi-size thumbnails are introduced (per ADR-004's future note), the aspect + pageCount are per-document and do not need to be duplicated per size.
|
||||
44
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
44
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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.
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 }
|
||||
] as const;
|
||||
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
|
||||
test.describe('Accessibility — /briefwechsel row layout', () => {
|
||||
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.waitForSelector('[data-hydrated]');
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('main')
|
||||
.analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||
.join('\n');
|
||||
console.log(
|
||||
`\nAccessibility violations on briefwechsel (${vp.name}/${theme}):\n${summary}`
|
||||
);
|
||||
}
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
43
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
43
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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.
|
||||
const VISUAL = process.env.VISUAL === '1';
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// 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.
|
||||
test.describe('snapshots', () => {
|
||||
test.skip(!VISUAL, 'VISUAL=1 required to compare baselines');
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 }
|
||||
] as const) {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
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 expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
|
||||
maxDiffPixels: 100,
|
||||
fullPage: true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
48
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
48
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { thumbnailUrl } from '$lib/thumbnails';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||
pageCount?: number;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
|
||||
const url = $derived(thumbnailUrl(doc));
|
||||
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
||||
const pageCount = $derived(doc.pageCount ?? 1);
|
||||
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="conv-thumb-tile"
|
||||
data-aspect={aspect}
|
||||
class="relative {tileClass} flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||
>
|
||||
{#if url}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
data-testid="conv-thumb-skeleton"
|
||||
class="h-full w-full bg-line/60 motion-safe:animate-pulse"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#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"
|
||||
>{pageCount}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
110
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
110
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
|
||||
import ConversationThumbnail from './ConversationThumbnail.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('ConversationThumbnail', () => {
|
||||
it('renders the thumbnail image with a cache-busting v= query param', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: '1111',
|
||||
thumbnailKey: 'thumbnails/1111.jpg',
|
||||
thumbnailGeneratedAt: '2026-04-10T09:00:00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
});
|
||||
|
||||
const img = document.querySelector('img') as HTMLImageElement | null;
|
||||
expect(img).not.toBeNull();
|
||||
expect(img!.getAttribute('src')).toContain('/api/documents/1111/thumbnail');
|
||||
expect(img!.getAttribute('src')).toContain('v=');
|
||||
});
|
||||
|
||||
it('uses portrait dimensions when aspect is PORTRAIT', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'p1',
|
||||
thumbnailKey: 'thumbnails/p1.jpg',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
});
|
||||
|
||||
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
|
||||
expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT');
|
||||
});
|
||||
|
||||
it('uses landscape dimensions when aspect is LANDSCAPE', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'l1',
|
||||
thumbnailKey: 'thumbnails/l1.jpg',
|
||||
thumbnailAspect: 'LANDSCAPE',
|
||||
pageCount: 1
|
||||
}
|
||||
});
|
||||
|
||||
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
|
||||
expect(tile.getAttribute('data-aspect')).toBe('LANDSCAPE');
|
||||
});
|
||||
|
||||
it('falls back to PORTRAIT when thumbnailAspect is missing', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'n1',
|
||||
thumbnailKey: 'thumbnails/n1.jpg'
|
||||
}
|
||||
});
|
||||
|
||||
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
|
||||
expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT');
|
||||
});
|
||||
|
||||
it('renders the page badge when pageCount is greater than 1', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'm1',
|
||||
thumbnailKey: 'thumbnails/m1.jpg',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 4
|
||||
}
|
||||
});
|
||||
|
||||
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]') as HTMLElement;
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge.textContent).toContain('4');
|
||||
});
|
||||
|
||||
it('hides the page badge when pageCount is 1 or missing', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 's1',
|
||||
thumbnailKey: 'thumbnails/s1.jpg',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
});
|
||||
|
||||
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]');
|
||||
expect(badge).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a skeleton placeholder when no thumbnailKey is set yet', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'blank',
|
||||
thumbnailAspect: 'PORTRAIT'
|
||||
}
|
||||
});
|
||||
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
const skeleton = document.querySelector('[data-testid="conv-thumb-skeleton"]');
|
||||
expect(skeleton).not.toBeNull();
|
||||
expect(skeleton!.className).toContain('motion-safe:animate-pulse');
|
||||
});
|
||||
});
|
||||
54
frontend/src/lib/components/DistributionBar.svelte
Normal file
54
frontend/src/lib/components/DistributionBar.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
outCount: number;
|
||||
inCount: number;
|
||||
senderName: string;
|
||||
receiverName: string;
|
||||
}
|
||||
|
||||
let { outCount, inCount, senderName, receiverName }: Props = $props();
|
||||
|
||||
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);
|
||||
</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}"
|
||||
>
|
||||
<div class="flex justify-between text-sm font-bold">
|
||||
<span class="inline-flex items-center gap-1 text-primary"
|
||||
>{outCount} von {shortSenderName}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="inline h-3.5 w-3.5 opacity-60"
|
||||
/></span
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 text-accent"
|
||||
>{inCount} von {shortReceiverName}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="inline h-3.5 w-3.5 opacity-60"
|
||||
/></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
|
||||
<div
|
||||
data-testid="dist-bar-segment"
|
||||
class="h-full bg-primary transition-all"
|
||||
style="width: {outPct}%"
|
||||
></div>
|
||||
<div
|
||||
data-testid="dist-bar-segment"
|
||||
class="h-full bg-accent transition-all"
|
||||
style="width: {100 - outPct}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
58
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal file
58
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
|
||||
import DistributionBar from './DistributionBar.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('DistributionBar', () => {
|
||||
it('renders both counts and short names, and the two-tone fill bar', async () => {
|
||||
render(DistributionBar, {
|
||||
outCount: 3,
|
||||
inCount: 7,
|
||||
senderName: 'Hans Müller',
|
||||
receiverName: 'Anna Schmidt'
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// 3/10 → 30% / 70% split on the two segments
|
||||
const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]');
|
||||
expect(segments).toHaveLength(2);
|
||||
expect((segments[0] as HTMLElement).style.width).toBe('30%');
|
||||
expect((segments[1] as HTMLElement).style.width).toBe('70%');
|
||||
});
|
||||
|
||||
it('falls back to the full name when it has no space to split', async () => {
|
||||
render(DistributionBar, {
|
||||
outCount: 1,
|
||||
inCount: 0,
|
||||
senderName: 'SingleWord',
|
||||
receiverName: 'Another'
|
||||
});
|
||||
|
||||
const container = document.querySelector('[role="img"]') as HTMLElement;
|
||||
expect(container.textContent).toContain('1 von SingleWord');
|
||||
});
|
||||
|
||||
it('renders a zero-percent left segment when outCount is zero', async () => {
|
||||
render(DistributionBar, {
|
||||
outCount: 0,
|
||||
inCount: 4,
|
||||
senderName: 'Hans',
|
||||
receiverName: 'Anna'
|
||||
});
|
||||
|
||||
const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]');
|
||||
expect((segments[0] as HTMLElement).style.width).toBe('0%');
|
||||
expect((segments[1] as HTMLElement).style.width).toBe('100%');
|
||||
});
|
||||
});
|
||||
110
frontend/src/lib/components/ThumbnailRow.svelte
Normal file
110
frontend/src/lib/components/ThumbnailRow.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { relativeYearsDe } from '$lib/relativeTime';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
title?: string;
|
||||
originalFilename: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
summary?: string;
|
||||
contentType?: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||
pageCount?: number;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[];
|
||||
tags?: Tag[];
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
isOut,
|
||||
showOtherParty,
|
||||
now
|
||||
}: {
|
||||
doc: Doc;
|
||||
isOut: boolean;
|
||||
showOtherParty: boolean;
|
||||
now?: Date;
|
||||
} = $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
|
||||
? (doc.receivers?.[0]?.displayName ?? '')
|
||||
: (doc.sender?.displayName ?? '')
|
||||
: ''
|
||||
);
|
||||
const relativeYearLabel = $derived(
|
||||
doc.documentDate
|
||||
? relativeYearsDe(new Date(doc.documentDate + 'T12:00:00'), now ?? new Date())
|
||||
: ''
|
||||
);
|
||||
const ariaLabel = $derived(
|
||||
`${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/documents/${doc.id}`}
|
||||
aria-label={ariaLabel}
|
||||
class="group flex min-h-[120px] items-start gap-3 border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 focus-within:bg-muted hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
|
||||
class:border-l-primary={isOut}
|
||||
class:border-l-accent={!isOut}
|
||||
>
|
||||
<ConversationThumbnail doc={doc} />
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<div class="min-w-0 flex-1 truncate text-sm font-bold text-ink">
|
||||
{title}
|
||||
</div>
|
||||
{#if relativeYearLabel}
|
||||
<div class="shrink-0 text-xs text-ink-3">{relativeYearLabel}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if doc.summary}
|
||||
<div class="line-clamp-2 text-sm text-ink-2 italic">
|
||||
“{doc.summary}”
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-[6px] gap-y-1 text-xs text-ink-3">
|
||||
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
||||
{#if doc.location}
|
||||
<span class="text-line">·</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
{#if otherPartyName}
|
||||
<span class="text-line">·</span>
|
||||
<span>{otherPartyName}</span>
|
||||
{/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}
|
||||
</div>
|
||||
</a>
|
||||
177
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
177
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
|
||||
import ThumbnailRow from './ThumbnailRow.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseDoc = {
|
||||
id: 'd1',
|
||||
title: 'Liebe Anna',
|
||||
originalFilename: 'liebe_anna.pdf',
|
||||
documentDate: '1950-06-01',
|
||||
location: 'Berlin',
|
||||
summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.',
|
||||
contentType: 'application/pdf',
|
||||
thumbnailKey: 'thumbnails/d1.jpg',
|
||||
thumbnailGeneratedAt: '2026-04-01T12:00:00Z',
|
||||
thumbnailAspect: 'PORTRAIT' as const,
|
||||
pageCount: 2,
|
||||
sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' },
|
||||
receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }],
|
||||
tags: [
|
||||
{ id: 't1', name: 'Familie' },
|
||||
{ id: 't2', name: 'Krieg' },
|
||||
{ id: 't3', name: 'Reise' },
|
||||
{ id: 't4', name: 'Arbeit' },
|
||||
{ id: 't5', name: 'Zuhause' }
|
||||
]
|
||||
};
|
||||
|
||||
describe('ThumbnailRow', () => {
|
||||
it('renders the title, date, location, and summary quote', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain('Liebe Anna');
|
||||
expect(document.body.textContent).toContain('Berlin');
|
||||
expect(document.body.textContent).toContain('Heute schreibe ich Dir');
|
||||
});
|
||||
|
||||
it('falls back to originalFilename when title is empty', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: { ...baseDoc, title: '' },
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain('liebe_anna.pdf');
|
||||
});
|
||||
|
||||
it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: true,
|
||||
showOtherParty: true,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
// Out-going from Hans, other party is first receiver (Anna Schmidt)
|
||||
expect(document.body.textContent).toContain('Anna Schmidt');
|
||||
});
|
||||
|
||||
it('hides the other-party name when showOtherParty=false (bilateral list)', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: false,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
// Anna is the receiver; in a bilateral list we suppress party names.
|
||||
expect(document.body.textContent).not.toContain('Anna Schmidt');
|
||||
});
|
||||
|
||||
it('renders at most 3 tag chips and signals any remainder with "+N"', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips.length).toBeLessThanOrEqual(3);
|
||||
expect(document.body.textContent).toMatch(/\+2/);
|
||||
});
|
||||
|
||||
it('renders relative-year label derived from documentDate', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
// 1950-06-01 → 2026-06-01 = 76 years
|
||||
expect(document.body.textContent).toContain('vor 76 Jahren');
|
||||
});
|
||||
|
||||
it('sets border-l class based on isOut', () => {
|
||||
const { unmount } = render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||
expect(link.className).toContain('border-l-primary');
|
||||
|
||||
unmount();
|
||||
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: false,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||
expect(link.className).toContain('border-l-accent');
|
||||
});
|
||||
|
||||
it('exposes a descriptive aria-label combining title and date', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: baseDoc,
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||
const label = link.getAttribute('aria-label') ?? '';
|
||||
expect(label).toContain('Liebe Anna');
|
||||
expect(label).toMatch(/1950/);
|
||||
});
|
||||
|
||||
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: {
|
||||
...baseDoc,
|
||||
summary: 'safe <img src=x onerror="alert(1)"> text'
|
||||
},
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
// No real img tag from the summary, the ConversationThumbnail img is fine.
|
||||
const imgs = document.querySelectorAll('img[onerror]');
|
||||
expect(imgs.length).toBe(0);
|
||||
expect(document.body.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
|
||||
it('handles missing optional fields without crashing', () => {
|
||||
render(ThumbnailRow, {
|
||||
doc: {
|
||||
id: 'n1',
|
||||
title: 'Ohne Datum',
|
||||
originalFilename: 'x.pdf',
|
||||
contentType: 'application/pdf',
|
||||
thumbnailAspect: 'PORTRAIT'
|
||||
},
|
||||
isOut: true,
|
||||
showOtherParty: false,
|
||||
now: new Date('2026-06-01T00:00:00Z')
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain('Ohne Datum');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { relativeTimeDe } from './relativeTime';
|
||||
import { relativeTimeDe, relativeYearsDe } from './relativeTime';
|
||||
|
||||
const NOW = new Date('2026-04-20T12:00:00Z');
|
||||
|
||||
@@ -39,3 +39,31 @@ describe('relativeTimeDe', () => {
|
||||
expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeYearsDe', () => {
|
||||
it('returns singular "vor 1 Jahr" for exactly one whole year ago', () => {
|
||||
const from = new Date('2025-04-20T12:00:00Z');
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor 1 Jahr');
|
||||
});
|
||||
|
||||
it('returns plural "vor N Jahren" for more than one year', () => {
|
||||
const from = new Date('1940-04-20T12:00:00Z');
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor 86 Jahren');
|
||||
});
|
||||
|
||||
it('floors a partial year down (eleven months ago = 0 years)', () => {
|
||||
const from = new Date('2025-06-01T00:00:00Z');
|
||||
// We show "vor weniger als 1 Jahr" rather than rounding up to 1.
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor weniger als 1 Jahr');
|
||||
});
|
||||
|
||||
it('returns empty string when the input Date is invalid', () => {
|
||||
const invalid = new Date('not-a-real-date');
|
||||
expect(relativeYearsDe(invalid, NOW)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for future dates', () => {
|
||||
const future = new Date('2030-01-01T00:00:00Z');
|
||||
expect(relativeYearsDe(future, NOW)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,3 +9,22 @@ export function relativeTimeDe(from: Date, now: Date = new Date()): string {
|
||||
if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) });
|
||||
return m.comment_time_days({ count: Math.round(minutes / 1440) });
|
||||
}
|
||||
|
||||
// "vor N Jahren" for a historical letter date relative to now. Computed from
|
||||
// calendar fields (not a constant ms-per-year) so that a letter from exactly
|
||||
// one year ago reports "vor 1 Jahr" rather than falling on the wrong side of
|
||||
// a leap-year rounding. Returns "" for invalid or future dates — the caller
|
||||
// should then hide the relative-time label rather than render a misleading
|
||||
// "vor 0 Jahren".
|
||||
export function relativeYearsDe(from: Date, now: Date = new Date()): string {
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(now.getTime())) return '';
|
||||
if (from.getTime() > now.getTime()) return '';
|
||||
let years = now.getUTCFullYear() - from.getUTCFullYear();
|
||||
const beforeAnniversary =
|
||||
now.getUTCMonth() < from.getUTCMonth() ||
|
||||
(now.getUTCMonth() === from.getUTCMonth() && now.getUTCDate() < from.getUTCDate());
|
||||
if (beforeAnniversary) years -= 1;
|
||||
if (years < 1) return 'vor weniger als 1 Jahr';
|
||||
if (years === 1) return 'vor 1 Jahr';
|
||||
return `vor ${years} Jahren`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DistributionBar from '$lib/components/DistributionBar.svelte';
|
||||
import ThumbnailRow from '$lib/components/ThumbnailRow.svelte';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
interface Props {
|
||||
documents: {
|
||||
@@ -9,14 +13,15 @@ interface Props {
|
||||
originalFilename: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
sender?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||
summary?: string;
|
||||
contentType?: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||
pageCount?: number;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[];
|
||||
tags?: Tag[];
|
||||
}[];
|
||||
senderId: string;
|
||||
receiverId?: string;
|
||||
@@ -51,31 +56,9 @@ const countsByYear = $derived(
|
||||
|
||||
const outCount = $derived(documents.filter((d) => d.sender?.id === senderId).length);
|
||||
const inCount = $derived(documents.length - outCount);
|
||||
const outPct = $derived(documents.length > 0 ? (outCount / documents.length) * 100 : 0);
|
||||
|
||||
const isBilateral = $derived(!!senderId && !!receiverId);
|
||||
|
||||
const shortSenderName = $derived(senderName?.split(' ')[0] ?? senderName ?? '');
|
||||
const shortReceiverName = $derived(receiverName?.split(' ')[0] ?? receiverName ?? '');
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PLACEHOLDER: 'bg-brand-sand',
|
||||
UPLOADED: 'bg-brand-mint',
|
||||
TRANSCRIBED: 'bg-brand-mint',
|
||||
REVIEWED: 'bg-brand-navy/70',
|
||||
ARCHIVED: 'bg-brand-navy'
|
||||
};
|
||||
return map[status] ?? 'bg-brand-sand';
|
||||
}
|
||||
|
||||
function otherPartyName(doc: (typeof documents)[number]): string {
|
||||
if (doc.sender?.id === senderId) {
|
||||
const r = doc.receivers?.[0];
|
||||
return r ? r.displayName : m.conv_no_party();
|
||||
}
|
||||
return doc.sender ? doc.sender.displayName : m.conv_no_party();
|
||||
}
|
||||
const showOtherParty = $derived(!receiverId);
|
||||
|
||||
const newDocUrl = $derived(
|
||||
`/documents/new?senderId=${encodeURIComponent(senderId)}${receiverId ? `&receiverId=${encodeURIComponent(receiverId)}` : ''}`
|
||||
@@ -83,36 +66,12 @@ const newDocUrl = $derived(
|
||||
</script>
|
||||
|
||||
{#if isBilateral && documents.length > 0}
|
||||
<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 ?? ''}"
|
||||
>
|
||||
<div class="flex justify-between text-sm font-bold">
|
||||
<span class="inline-flex items-center gap-1 text-primary"
|
||||
>{outCount} von {shortSenderName}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="inline h-3.5 w-3.5 opacity-60"
|
||||
/></span
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 text-accent"
|
||||
>{inCount} von {shortReceiverName}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="inline h-3.5 w-3.5 opacity-60"
|
||||
/></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
|
||||
<div class="h-full bg-primary transition-all" style="width: {outPct}%"></div>
|
||||
<div class="h-full bg-accent transition-all" style="width: {100 - outPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<DistributionBar
|
||||
outCount={outCount}
|
||||
inCount={inCount}
|
||||
senderName={senderName ?? ''}
|
||||
receiverName={receiverName ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-hidden rounded-sm border border-line bg-surface">
|
||||
@@ -127,50 +86,7 @@ const newDocUrl = $derived(
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label="{doc.title || doc.originalFilename}, {doc.documentDate
|
||||
? formatDate(doc.documentDate)
|
||||
: ''}"
|
||||
class="group flex min-h-[44px] cursor-pointer items-center gap-[9px] border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-muted"
|
||||
class:border-l-primary={isOut}
|
||||
class:border-l-accent={!isOut}
|
||||
>
|
||||
<img
|
||||
src={isOut
|
||||
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
|
||||
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 shrink-0 opacity-60"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-[2px] truncate text-sm font-bold text-ink">
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
<div class="flex items-center gap-[5px] text-sm text-ink-3">
|
||||
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
||||
{#if doc.location}
|
||||
<span class="text-line">·</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
{#if !receiverId}
|
||||
<span class="text-line">·</span>
|
||||
<span>{otherPartyName(doc)}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="ml-[3px] h-[6px] w-[6px] shrink-0 rounded-full {statusDotClass(doc.status)}"
|
||||
title={doc.status}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 text-sm text-ink-3 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true">›</span
|
||||
>
|
||||
</a>
|
||||
<ThumbnailRow doc={doc} isOut={isOut} showOtherParty={showOtherParty} />
|
||||
{/each}
|
||||
|
||||
{#if canWrite}
|
||||
|
||||
@@ -30,6 +30,21 @@ const withPersons = {
|
||||
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
||||
};
|
||||
|
||||
const hansPerson = {
|
||||
id: 'p1',
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
personType: 'PERSON' as const,
|
||||
displayName: 'Hans Müller'
|
||||
};
|
||||
const annaPerson = {
|
||||
id: 'p2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
personType: 'PERSON' as const,
|
||||
displayName: 'Anna Schmidt'
|
||||
};
|
||||
|
||||
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'd1',
|
||||
title: 'Testbrief',
|
||||
@@ -39,8 +54,8 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
location: 'Berlin',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
|
||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
|
||||
sender: hansPerson,
|
||||
receivers: [annaPerson],
|
||||
tags: [],
|
||||
transcription: undefined,
|
||||
filePath: undefined,
|
||||
@@ -201,6 +216,32 @@ describe('Briefwechsel page – swap button', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Distribution bar (bilateral only) ────────────────────────────────────────
|
||||
|
||||
describe('Briefwechsel page – distribution bar', () => {
|
||||
it('renders the DistributionBar when both persons are set and there are documents', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ id: 'out1', sender: hansPerson, receivers: [annaPerson] }),
|
||||
makeDoc({ id: 'in1', sender: annaPerson, receivers: [hansPerson] }),
|
||||
makeDoc({ id: 'in2', sender: annaPerson, receivers: [hansPerson] })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
const bar = document.querySelector('[role="img"][aria-label*="Briefverteilung"]');
|
||||
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');
|
||||
});
|
||||
|
||||
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"]');
|
||||
expect(bar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Year dividers ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Briefwechsel page – year dividers', () => {
|
||||
|
||||
Reference in New Issue
Block a user