Compare commits

...

10 Commits

Author SHA1 Message Date
Marcel
f74fe16747 docs(adr): ADR-005 thumbnailAspect + pageCount alongside the thumbnail
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m14s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 3m5s
CI / Unit & Component Tests (pull_request) Failing after 2m48s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
Captures the reasoning behind persisting two scalar columns on
documents rather than deriving aspect client-side or standing up a
thumbnail_metadata table. Also documents the 1.1 landscape threshold,
the null-during-rollout state, and the ordering invariants inside
ThumbnailService.generate().

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:54:08 +02:00
Marcel
63c0395151 test(briefwechsel): axe sweep at 3 viewports x 2 themes
Adds a dedicated axe-core sweep for /briefwechsel so contrast or
semantic regressions on the new row layout fail independently of
the catch-all accessibility suite. Scoped to the main landmark so
shared chrome violations (if any) aren't double-reported.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:52:38 +02:00
Marcel
22a4e49794 test(briefwechsel): scaffold visual-regression spec for row layout
Adds a Playwright spec gated on VISUAL=1 with one snapshot per
(mobile/tablet/desktop × light/dark) = 6 baselines. Snapshots stay
skipped in CI until the baseline set is captured and committed —
running `playwright test --update-snapshots briefwechsel-rows`
against a seeded backend generates them.

Structural check runs unconditionally so the file is wired into CI
today rather than waiting for the baseline capture step.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:51:16 +02:00
Marcel
c004c3bc6f test(briefwechsel): cover DistributionBar and fix Person fixture shape
Adds two new assertions for the extracted DistributionBar — it must
appear in bilateral mode and stay hidden in single-person mode — and
repairs the shared makeDoc fixture: the embedded Person now carries
personType + displayName so the fixture matches the regenerated
Document schema without TypeScript complaints.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:49:21 +02:00
Marcel
80728200c6 refactor(briefwechsel): ConversationTimeline renders ThumbnailRow per letter
Drops the inline row markup, arrow icons, status-dot helper, and the
otherPartyName helper that only fed it. Each visible row is now a
ThumbnailRow, which owns its own aria-label, border color, meta and
tag rendering. The year-divider and "new document" footer are
untouched — they were always intended to stay as timeline chrome.

Also widens the documents prop shape to include the summary, tags
and thumbnail metadata that ThumbnailRow consumes; the backend
already returns these fields via the Document schema so no server
change was required.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:47:41 +02:00
Marcel
dc60d27f20 feat(briefwechsel): add ThumbnailRow for the new correspondence row layout
Combines ConversationThumbnail with a quote-styled summary, truncated
meta line, and up to three tag chips (the rest collapsed into "+N").
The colored left border tells a reader at a glance whether this
letter left or entered the perspective person's mailbox — replacing
the previous status dot + script-type icons that were too busy for
the list view. Relative-year label ("vor 76 Jahren") is derived from
documentDate so the list carries temporal context without a full
date column.

Rendering rules:
- title falls back to originalFilename when empty
- summary uses a text expression, never {@html}, so inline markup
  in the summary field is escaped (XSS regression test locks this)
- focus-visible outline + focus-within hover keep keyboard-only
  users in sync with mouse hover feedback
- aria-label always pairs title with the formatted date so screen
  readers hear both identifiers

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:45:08 +02:00
Marcel
15d9bb1b78 feat(briefwechsel): add ConversationThumbnail with aspect + page badge
Reads thumbnailAspect from the backend and swaps between a 120×168
portrait tile and a 168×120 landscape tile so postcards and photos
don't get cropped into a portrait frame. Shows a page-count badge
top-right for multi-page PDFs, and a pulsing skeleton while the
async thumbnail job hasn't run yet. URL assembly goes through the
existing thumbnailUrl helper so cache-busting stays consistent
with DocumentThumbnail.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:40:15 +02:00
Marcel
9b5c1f64ba feat(relativeTime): add relativeYearsDe helper for historical letter dates
The correspondence timeline labels each row with its distance from today
("vor 86 Jahren"). Uses calendar-field math so the anniversary day
flips exactly — an ms-based 365.25d average misses by a day on leap
years. Invalid / future dates return "" so the caller can hide the
label rather than print "vor 0 Jahren".

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:36:43 +02:00
Marcel
10be13e6cd refactor(briefwechsel): ConversationTimeline consumes DistributionBar
Drops the inline bilateral-distribution markup and the short-name /
percentage helpers that only existed to feed it. ConversationTimeline
now hands senderName, receiverName, and the two counts to the shared
component and lets it own the rendering.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:33:53 +02:00
Marcel
bc337fb445 refactor(briefwechsel): extract bilateral DistributionBar component
Lifts the inline distribution bar out of ConversationTimeline so the
same two-tone ratio widget can be reused on other bilateral surfaces
(e.g. the person detail page). Markup/styling is byte-identical to
the inline version; only the prop interface is new.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:32:04 +02:00
13 changed files with 809 additions and 109 deletions

View 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.

View 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([]);
});
}
}
});

View 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
});
});
}
}
});
});

View 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>

View 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');
});
});

View 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>

View 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%');
});
});

View 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">
&ldquo;{doc.summary}&rdquo;
</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>

View 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');
});
});

View File

@@ -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('');
});
});

View File

@@ -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`;
}

View File

@@ -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}

View File

@@ -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', () => {