diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index d2cea5da..dc66fb5d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -50,6 +50,13 @@ public class Document { @Column(name = "thumbnail_generated_at") private LocalDateTime thumbnailGeneratedAt; + @Enumerated(EnumType.STRING) + @Column(name = "thumbnail_aspect", length = 16) + private ThumbnailAspect thumbnailAspect; + + @Column(name = "page_count") + private Integer pageCount; + // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf") @Column(name = "original_filename", nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/ThumbnailAspect.java b/backend/src/main/java/org/raddatz/familienarchiv/model/ThumbnailAspect.java new file mode 100644 index 00000000..630247fd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/ThumbnailAspect.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.model; + +public enum ThumbnailAspect { + PORTRAIT, + LANDSCAPE +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java index 3b4cb48e..f654b922 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -7,6 +7,7 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -45,6 +46,9 @@ public class ThumbnailService { private static final int THUMBNAIL_WIDTH = 240; private static final float JPEG_QUALITY = 0.85f; private static final int PDF_RENDER_DPI = 100; + // Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should + // render in the portrait tile rather than flipping to landscape at 1.01. + private static final float LANDSCAPE_THRESHOLD = 1.1f; private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final Set IMAGE_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/tiff"); @@ -82,27 +86,46 @@ public class ThumbnailService { return Outcome.SKIPPED; } - BufferedImage source = readSourceImage(doc, contentType); - if (source == null) return Outcome.FAILED; + SourcePreview preview = readSourcePreview(doc, contentType); + if (preview == null + || preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) { + log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId()); + return Outcome.FAILED; + } - byte[] jpeg = encodeThumbnail(source, doc.getId()); + byte[] jpeg = encodeThumbnail(preview.image(), doc.getId()); if (jpeg == null) return Outcome.FAILED; String thumbnailKey = thumbnailKeyFor(doc.getId()); if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED; - return persistThumbnailMetadata(doc, thumbnailKey); + ThumbnailResult result = new ThumbnailResult( + thumbnailKey, aspectOf(preview.image()), preview.pageCount()); + return persistThumbnailMetadata(doc, result); } + private static ThumbnailAspect aspectOf(BufferedImage source) { + float ratio = (float) source.getWidth() / source.getHeight(); + return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT; + } + + // First-page image + total page count for the source file. Page count is always + // 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; } - private BufferedImage readSourceImage(Document doc, String contentType) { + private SourcePreview readSourcePreview(Document doc, String contentType) { try { return PDF_CONTENT_TYPE.equals(contentType) ? renderPdfFirstPage(doc.getFilePath()) - : readImage(doc.getFilePath()); + : new SourcePreview(readImage(doc.getFilePath()), 1); } catch (Exception e) { log.warn("Thumbnail source read failed for doc={} reason={}", doc.getId(), e.getMessage()); @@ -138,10 +161,12 @@ public class ThumbnailService { } } - private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey) { + private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) { try { - doc.setThumbnailKey(thumbnailKey); + doc.setThumbnailKey(result.key()); doc.setThumbnailGeneratedAt(LocalDateTime.now()); + doc.setThumbnailAspect(result.aspect()); + doc.setPageCount(result.pageCount()); documentRepository.save(doc); return Outcome.SUCCESS; } catch (Exception e) { @@ -151,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; } } @@ -160,11 +185,12 @@ public class ThumbnailService { return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType); } - private BufferedImage renderPdfFirstPage(String s3Key) throws IOException { + private SourcePreview renderPdfFirstPage(String s3Key) throws IOException { try (InputStream in = fileService.downloadFileStream(s3Key); PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) { PDFRenderer renderer = new PDFRenderer(pdf); - return renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB); + BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB); + return new SourcePreview(image, pdf.getNumberOfPages()); } } diff --git a/backend/src/main/resources/db/migration/V53__add_thumbnail_aspect_and_page_count.sql b/backend/src/main/resources/db/migration/V53__add_thumbnail_aspect_and_page_count.sql new file mode 100644 index 00000000..53d39dc2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V53__add_thumbnail_aspect_and_page_count.sql @@ -0,0 +1,8 @@ +-- Adds two nullable metadata columns populated by ThumbnailService when it +-- generates the JPEG preview: thumbnail_aspect (PORTRAIT | LANDSCAPE, from the +-- source image w/h ratio with threshold 1.1) and page_count (from PDDocument +-- for PDFs, 1 for image uploads). Both are null until the existing admin +-- backfill endpoint (/api/admin/generate-thumbnails) reruns the service. +ALTER TABLE documents + ADD COLUMN thumbnail_aspect VARCHAR(16), + ADD COLUMN page_count INTEGER; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java index 8d12a678..28728bb7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; @@ -65,6 +66,40 @@ class DocumentRepositoryTest { assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER); } + // ─── thumbnailAspect + pageCount round-trip ─────────────────────────────── + + @Test + void save_persistsThumbnailAspectAndPageCount() { + Document document = Document.builder() + .title("Mit Aspekt") + .originalFilename("aspect.pdf") + .status(DocumentStatus.UPLOADED) + .thumbnailAspect(ThumbnailAspect.LANDSCAPE) + .pageCount(7) + .build(); + + Document saved = documentRepository.save(document); + Document found = documentRepository.findById(saved.getId()).orElseThrow(); + + assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE); + assertThat(found.getPageCount()).isEqualTo(7); + } + + @Test + void save_thumbnailAspectAndPageCount_defaultToNull() { + Document document = Document.builder() + .title("Ohne Aspekt") + .originalFilename("no_aspect.pdf") + .status(DocumentStatus.PLACEHOLDER) + .build(); + + Document saved = documentRepository.save(document); + Document found = documentRepository.findById(saved.getId()).orElseThrow(); + + assertThat(found.getThumbnailAspect()).isNull(); + assertThat(found.getPageCount()).isNull(); + } + // ─── findByStatus ───────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index b482a0be..e751b85d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -302,6 +302,51 @@ class MigrationIntegrationTest { ).isInstanceOf(DataIntegrityViolationException.class); } + // ─── V53: add thumbnail_aspect + page_count columns to documents ───────── + + @Test + void v53_thumbnailAspectColumn_existsAndIsNullable() { + UUID docId = createDocument(); + + // Column must exist and accept NULL (freshly-created doc has no thumbnail yet) + String aspect = jdbc.queryForObject( + "SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId); + assertThat(aspect).isNull(); + } + + @Test + void v53_pageCountColumn_existsAndIsNullable() { + UUID docId = createDocument(); + + Integer pageCount = jdbc.queryForObject( + "SELECT page_count FROM documents WHERE id = ?", Integer.class, docId); + assertThat(pageCount).isNull(); + } + + @Test + void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() { + UUID docId = createDocument(); + + int portraitRows = jdbc.update( + "UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId); + assertThat(portraitRows).isEqualTo(1); + + int landscapeRows = jdbc.update( + "UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId); + assertThat(landscapeRows).isEqualTo(1); + } + + @Test + void v53_pageCountColumn_storesInteger() { + UUID docId = createDocument(); + + jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId); + + Integer stored = jdbc.queryForObject( + "SELECT page_count FROM documents WHERE id = ?", Integer.class, docId); + assertThat(stored).isEqualTo(4); + } + // ─── V51: backfill annotation_id on block comments and notifications ───── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java index 90b0e8c8..ad423401 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.test.util.ReflectionTestUtils; import software.amazon.awssdk.core.sync.RequestBody; @@ -167,6 +168,116 @@ class ThumbnailServiceTest { verify(documentRepository, never()).save(any()); } + @Test + void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException { + // Image uploads are always a single page from the user's perspective. + Document doc = makeDoc("image/png", "documents/scan.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getPageCount()).isEqualTo(1); + } + + @Test + void generate_persistsPageCount_fromPdfDocument() throws IOException { + Document doc = makeDoc("application/pdf", "documents/multi.pdf"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePdf(3))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getPageCount()).isEqualTo(3); + } + + @Test + void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException { + // 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT. + Document doc = makeDoc("image/png", "documents/portrait.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(600, 800))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + + @Test + void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException { + // 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE. + Document doc = makeDoc("image/jpeg", "documents/wide.jpg"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE); + } + + @Test + void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException { + // 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often + // come in at near-square and we want them to live in the portrait tile). + Document doc = makeDoc("image/png", "documents/square.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(500, 500))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + + @Test + void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException { + // 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT. + Document doc = makeDoc("image/png", "documents/near_threshold.png"); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000))); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS); + assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT); + } + + @Test + void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException { + // Truncated JPEG header — ImageIO returns null rather than throwing. + // Without the corrupt-image guard this would later NPE inside the aspect / + // dimension computation in scaleToWidth. + Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg"); + byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0}; + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(truncated)); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + verifyNoInteractions(s3Client); + verify(documentRepository, never()).save(any()); + } + + @Test + void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException { + // "PDF" header but no body — PDFBox throws IOException while loading. + Document doc = makeDoc("application/pdf", "documents/corrupt.pdf"); + byte[] fakePdf = "%PDF-1.4\n".getBytes(); + when(fileService.downloadFileStream(anyString())) + .thenReturn(new ByteArrayInputStream(fakePdf)); + + ThumbnailService.Outcome outcome = thumbnailService.generate(doc); + + assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); + verifyNoInteractions(s3Client); + verify(documentRepository, never()).save(any()); + } + @Test void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException { // Covers the "orphan thumbnail" edge case: S3 upload succeeded but the @@ -202,15 +313,21 @@ class ThumbnailServiceTest { } private static byte[] createSamplePdf() throws IOException { + return createSamplePdf(1); + } + + private static byte[] createSamplePdf(int pageCount) throws IOException { try (PDDocument doc = new PDDocument()) { - PDPage page = new PDPage(PDRectangle.A4); - doc.addPage(page); - try (PDPageContentStream content = new PDPageContentStream(doc, page)) { - content.beginText(); - content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24); - content.newLineAtOffset(100, 700); - content.showText("Lieber Hans,"); - content.endText(); + for (int i = 0; i < pageCount; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream content = new PDPageContentStream(doc, page)) { + content.beginText(); + content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24); + content.newLineAtOffset(100, 700); + content.showText("Lieber Hans,"); + content.endText(); + } } ByteArrayOutputStream bos = new ByteArrayOutputStream(); doc.save(bos); diff --git a/docs/adr/005-thumbnail-aspect-and-page-count.md b/docs/adr/005-thumbnail-aspect-and-page-count.md new file mode 100644 index 00000000..9c21e8fe --- /dev/null +++ b/docs/adr/005-thumbnail-aspect-and-page-count.md @@ -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. diff --git a/frontend/e2e/briefwechsel-a11y.spec.ts b/frontend/e2e/briefwechsel-a11y.spec.ts new file mode 100644 index 00000000..74d659b9 --- /dev/null +++ b/frontend/e2e/briefwechsel-a11y.spec.ts @@ -0,0 +1,65 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect } from '@playwright/test'; +import { + seedBilateralPair, + cleanupBilateralPair, + type BilateralPair +} from './fixtures/bilateral-correspondence'; + +// Accessibility coverage for the briefwechsel thumbnail-row layout. Seeds +// two persons + a bilateral document via the shared fixture 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 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 800 } +] as const; + +const THEMES = ['light', 'dark'] as const; + +let pair: BilateralPair; + +test.describe('Accessibility — /briefwechsel row layout', () => { + test.beforeAll(async ({ request }) => { + pair = await seedBilateralPair(request, 'A11y'); + }); + + test.afterAll(async ({ request }) => { + await cleanupBilateralPair(request, pair); + }); + + 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?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.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') + .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([]); + }); + } + } +}); diff --git a/frontend/e2e/briefwechsel-rows.visual.spec.ts b/frontend/e2e/briefwechsel-rows.visual.spec.ts new file mode 100644 index 00000000..3b0991bb --- /dev/null +++ b/frontend/e2e/briefwechsel-rows.visual.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; +import { + seedBilateralPair, + cleanupBilateralPair, + type BilateralPair +} from './fixtures/bilateral-correspondence'; + +// Visual + structural coverage for the new briefwechsel row layout. +// +// Seeds a bilateral correspondence pair via the shared fixture 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 pair: BilateralPair; + +test.describe('Briefwechsel — thumbnail-row layout', () => { + test.beforeAll(async ({ request }) => { + pair = await seedBilateralPair(request, 'Visual'); + }); + + test.afterAll(async ({ request }) => { + await cleanupBilateralPair(request, pair); + }); + + async function openBilateral(page: import('@playwright/test').Page) { + await page.goto( + `/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}` + ); + await page.waitForSelector('[data-hydrated]'); + // Parity with the a11y spec: fail loudly if we ever end up on the hero + // instead of the row layout. + await expect(page.getByTestId('conv-person-bar')).toBeVisible(); + } + + 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'); + + 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 openBilateral(page); + await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, { + maxDiffPixels: 100, + fullPage: true + }); + }); + } + } + }); +}); diff --git a/frontend/e2e/fixtures/bilateral-correspondence.ts b/frontend/e2e/fixtures/bilateral-correspondence.ts new file mode 100644 index 00000000..ee32ef96 --- /dev/null +++ b/frontend/e2e/fixtures/bilateral-correspondence.ts @@ -0,0 +1,62 @@ +import type { APIRequestContext } from '@playwright/test'; + +/** + * Test fixture for the briefwechsel row layout. + * + * Creates two persons and one document with sender/receiver between them so + * that `/briefwechsel?senderId=X&receiverId=Y` navigates straight to the row + * state (not the hero). Each seed uses a `Date.now()`-suffixed last name so + * parallel runs and reruns never collide. + * + * The backend does not expose a person-delete endpoint, so only the document + * is cleaned up in {@link cleanupBilateralPair}. The two timestamped persons + * remain in the DB — acceptable for the test environment, and the unique + * suffix means they cannot conflict with later runs. + */ + +export interface BilateralPair { + senderId: string; + receiverId: string; + documentId: string; +} + +export async function seedBilateralPair( + request: APIRequestContext, + prefix: string +): Promise { + const timestamp = Date.now(); + + const senderRes = await request.post('/api/persons', { + data: { firstName: prefix, lastName: `Sender-${timestamp}` } + }); + if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`); + const senderId = (await senderRes.json()).id as string; + + const receiverRes = await request.post('/api/persons', { + data: { firstName: prefix, lastName: `Receiver-${timestamp}` } + }); + if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`); + const receiverId = (await receiverRes.json()).id as string; + + const docRes = await request.post('/api/documents', { + multipart: { + title: `${prefix} Brief`, + documentDate: '1950-06-15', + senderId, + receiverIds: receiverId + } + }); + if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`); + const documentId = (await docRes.json()).id as string; + + return { senderId, receiverId, documentId }; +} + +export async function cleanupBilateralPair( + request: APIRequestContext, + pair: BilateralPair +): Promise { + // Only the document is purged — the backend has no person-delete endpoint + // and the timestamped last names make orphaned person rows safe to leave. + await request.delete(`/api/documents/${pair.documentId}`); +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7df88f45..f74c749a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -165,6 +165,10 @@ "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}", + "row_direction_sent": "Gesendet", + "row_direction_received": "Empfangen", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", "admin_tab_groups": "Gruppen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index dc5cbc93..0ff58ce2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -165,6 +165,10 @@ "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}", + "row_direction_sent": "Sent", + "row_direction_received": "Received", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", "admin_tab_groups": "Groups", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 80ea31a0..f968bbde 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -165,6 +165,10 @@ "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}", + "row_direction_sent": "Enviada", + "row_direction_received": "Recibida", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", "admin_tab_groups": "Grupos", diff --git a/frontend/src/lib/components/ConversationThumbnail.svelte b/frontend/src/lib/components/ConversationThumbnail.svelte new file mode 100644 index 00000000..376f0c40 --- /dev/null +++ b/frontend/src/lib/components/ConversationThumbnail.svelte @@ -0,0 +1,48 @@ + + +
+ {#if url} + + {:else} + + {/if} + + {#if pageCount > 1} + {pageCount} + {/if} +
diff --git a/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts b/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts new file mode 100644 index 00000000..ccebd29b --- /dev/null +++ b/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts @@ -0,0 +1,113 @@ +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'); + // 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', () => { + 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'); + }); +}); diff --git a/frontend/src/lib/components/DistributionBar.svelte b/frontend/src/lib/components/DistributionBar.svelte new file mode 100644 index 00000000..4b9a09ad --- /dev/null +++ b/frontend/src/lib/components/DistributionBar.svelte @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/lib/components/DistributionBar.svelte.spec.ts b/frontend/src/lib/components/DistributionBar.svelte.spec.ts new file mode 100644 index 00000000..4a3ecb8c --- /dev/null +++ b/frontend/src/lib/components/DistributionBar.svelte.spec.ts @@ -0,0 +1,71 @@ +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'; + +afterEach(() => { + cleanup(); +}); + +describe('DistributionBar', () => { + it('renders the Paraglide aria-label and visible segments', 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(); + + // 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"]'); + 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; + 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 () => { + 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%'); + }); +}); diff --git a/frontend/src/lib/components/TagChipList.svelte b/frontend/src/lib/components/TagChipList.svelte new file mode 100644 index 00000000..92a5df9c --- /dev/null +++ b/frontend/src/lib/components/TagChipList.svelte @@ -0,0 +1,23 @@ + + +{#if tags.length > 0} +
+ {#each displayedTags as tag (tag.id)} + {tag.name} + {/each} + {#if hiddenTagCount > 0} + +{hiddenTagCount} + {/if} +
+{/if} diff --git a/frontend/src/lib/components/TagChipList.svelte.spec.ts b/frontend/src/lib/components/TagChipList.svelte.spec.ts new file mode 100644 index 00000000..ae5542c6 --- /dev/null +++ b/frontend/src/lib/components/TagChipList.svelte.spec.ts @@ -0,0 +1,41 @@ +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(/\+/); + }); + + it('defaults max to 3 when the prop is omitted', () => { + render(TagChipList, { tags: makeTags(5) }); + const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]'); + expect(chips).toHaveLength(3); + expect(document.body.textContent).toMatch(/\+2/); + }); +}); diff --git a/frontend/src/lib/components/ThumbnailRow.svelte b/frontend/src/lib/components/ThumbnailRow.svelte new file mode 100644 index 00000000..f5770211 --- /dev/null +++ b/frontend/src/lib/components/ThumbnailRow.svelte @@ -0,0 +1,96 @@ + + + + + +
+
+
+ {title} +
+ {#if relativeYearLabel} +
{relativeYearLabel}
+ {/if} +
+ + {#if doc.summary} +
+ “{doc.summary}” +
+ {/if} + +
+ {doc.documentDate ? formatDate(doc.documentDate) : '—'} + {#if doc.location} + · + {doc.location} + {/if} + {#if otherPartyName} + · + {otherPartyName} + {/if} +
+ + +
+
diff --git a/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts new file mode 100644 index 00000000..e62b45ea --- /dev/null +++ b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import * as m from '$lib/paraglide/messages.js'; + +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('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, + 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 direction, 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') ?? ''; + // Direction label routes through Paraglide so EN / ES users don't hear + // "Gesendet" in their screen reader. + expect(label.startsWith(`${m.row_direction_sent()}:`)).toBe(true); + expect(label).toContain('Liebe Anna'); + expect(label).toMatch(/1950/); + }); + + it('aria-label leads with the received direction label 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; + const label = link.getAttribute('aria-label') ?? ''; + expect(label.startsWith(`${m.row_direction_received()}:`)).toBe(true); + }); + + it('does not inject raw HTML when summary contains markup (XSS regression)', () => { + render(ThumbnailRow, { + doc: { + ...baseDoc, + summary: 'safe 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(''); + }); + + 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'); + }); +}); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index f2a0f090..351e1597 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1386,6 +1386,10 @@ export interface components { thumbnailKey?: string; /** Format: date-time */ thumbnailGeneratedAt?: string; + /** @enum {string} */ + thumbnailAspect?: "PORTRAIT" | "LANDSCAPE"; + /** Format: int32 */ + pageCount?: number; originalFilename: string; /** @enum {string} */ status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; diff --git a/frontend/src/lib/relativeTime.spec.ts b/frontend/src/lib/relativeTime.spec.ts index 1855d07d..2a39f8ac 100644 --- a/frontend/src/lib/relativeTime.spec.ts +++ b/frontend/src/lib/relativeTime.spec.ts @@ -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(''); + }); +}); diff --git a/frontend/src/lib/relativeTime.ts b/frontend/src/lib/relativeTime.ts index f2e45a7e..ef6f80d7 100644 --- a/frontend/src/lib/relativeTime.ts +++ b/frontend/src/lib/relativeTime.ts @@ -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`; +} diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte index 31b45467..e1a342c1 100644 --- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte +++ b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte @@ -1,6 +1,10 @@ {#if isBilateral && documents.length > 0} - + {/if}
@@ -127,50 +86,7 @@ const newDocUrl = $derived(
{/if} - - - -
-
- {doc.title || doc.originalFilename} -
-
- {doc.documentDate ? formatDate(doc.documentDate) : '—'} - {#if doc.location} - · - {doc.location} - {/if} - {#if !receiverId} - · - {otherPartyName(doc)} - {/if} - -
-
- - -
+ {/each} {#if canWrite} diff --git a/frontend/src/routes/briefwechsel/page.svelte.spec.ts b/frontend/src/routes/briefwechsel/page.svelte.spec.ts index 9994ba58..d861f196 100644 --- a/frontend/src/routes/briefwechsel/page.svelte.spec.ts +++ b/frontend/src/routes/briefwechsel/page.svelte.spec.ts @@ -30,6 +30,23 @@ const withPersons = { filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' } }; +const makePerson = (overrides: Record = {}) => ({ + id: 'p1', + firstName: 'Hans', + lastName: 'Müller', + personType: 'PERSON' as const, + displayName: 'Hans Müller', + ...overrides +}); + +const hansPerson = makePerson(); +const annaPerson = makePerson({ + id: 'p2', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt' +}); + const makeDoc = (overrides: Record = {}) => ({ id: 'd1', title: 'Testbrief', @@ -39,8 +56,15 @@ const makeDoc = (overrides: Record = {}) => ({ location: 'Berlin', metadataComplete: false, scriptType: 'UNKNOWN' as const, - sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' }, - receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }], + sender: makePerson(), + receivers: [ + makePerson({ + id: 'p2', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt' + }) + ], tags: [], transcription: undefined, filePath: undefined, @@ -201,6 +225,48 @@ 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"]'); + expect(bar).not.toBeNull(); + 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"]'); + 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 ──────────────────────────────────────────────────────────── describe('Briefwechsel page – year dividers', () => {