feat(briefwechsel): thumbnail rows with summary quote and bilateral distribution bar (#305) #311
@@ -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)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
public enum ThumbnailAspect {
|
||||
PORTRAIT,
|
||||
LANDSCAPE
|
||||
}
|
||||
@@ -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<String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# ADR-005: thumbnailAspect + pageCount alongside the thumbnail
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #305 rebalances the /briefwechsel correspondence list into PDF-thumbnail rows. Two pieces of metadata are needed at row-render time:
|
||||
|
||||
- **Aspect ratio** — postcards are landscape (7:5), letters are portrait (5:7). Forcing landscape scans into a portrait tile crops away the signature; forcing portrait scans into a landscape tile wastes horizontal real estate.
|
||||
- **Page count** — multi-page letters should show a "N" badge on their thumbnail so the reader can tell a single-page note from a seven-page letter without clicking in.
|
||||
|
||||
Both values are cheap to derive at the point the thumbnail is generated (the source image is already decoded; the PDF is already loaded) and impossible to derive cheaply later (requires re-reading the S3 object).
|
||||
|
||||
## Decision
|
||||
|
||||
Persist both values as columns on `documents` and populate them inside `ThumbnailService.generate()` — the same code path that writes the JPEG to S3 and stamps `thumbnail_generated_at`.
|
||||
|
||||
- `thumbnail_aspect VARCHAR(16)` mapped to a Java enum `ThumbnailAspect` with two values: `PORTRAIT`, `LANDSCAPE`.
|
||||
- `page_count INTEGER` — `PDDocument.getNumberOfPages()` for PDFs, `1` for image uploads.
|
||||
- Aspect threshold is `source.width / source.height > 1.1` → `LANDSCAPE`; everything else (including near-square A4 scans at ratio ≈ 1.0) stays `PORTRAIT`. The 1.1 margin keeps borderline scans from flipping across the threshold on a rounding error.
|
||||
- Both columns are nullable and remain `null` for historical documents until the existing `/api/admin/generate-thumbnails` backfill rerun populates them.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Derive aspect client-side after image load | First-paint would have all tiles in portrait, then reshuffle into landscape when the JPEG decodes — a visible jank on slow networks. The backend already has the dimensions; client-side recomputation is a waste. |
|
||||
| Store full `width` / `height` columns | Not needed anywhere — consumers want the categorical answer. If a future feature needs exact dimensions, they can be added later without migrating existing rows. |
|
||||
| A separate `thumbnail_metadata` table | Two scalar nullable columns aren't worth a join. See ADR-004 — thumbnails are modeled as a cross-cutting aspect of `Document`, not a sub-domain. |
|
||||
| Derive page count from the existing PDF at render time on the frontend | Duplicates work already done on the backend and requires a separate byte-range fetch of the PDF header. Frontend already gets `pageCount` "for free" via the Document response. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- `ConversationThumbnail.svelte` picks the tile dimensions from `thumbnailAspect` directly — no async measurement, no layout shift.
|
||||
- `ThumbnailRow` reads `pageCount` synchronously for the badge. Multi-page letters are distinguishable at first paint.
|
||||
- Backfill runs the same migration path for every old document — re-executing generates the aspect + pageCount columns along with the JPEG, so operators don't have a second admin button to click.
|
||||
|
||||
**Harder:**
|
||||
- Both columns are `null` for every document until the backfill runs on a given instance. Frontend components guard with `?? 'PORTRAIT'` / `?? 1` so the UI stays sensible during the rollout window. The backfill is idempotent and cheap (reuses existing S3 object), so re-running it is the simplest recovery path.
|
||||
- The aspect threshold is a single constant in Java. A future need to tune per-type (e.g. postcards vs photos) means a code change, not a configuration change — acceptable for a single-operator archive.
|
||||
|
||||
### Ordering inside `ThumbnailService.generate()`
|
||||
|
||||
Aspect computation happens AFTER the JPEG upload succeeds but BEFORE the entity save — if the save throws, the columns rewind with it. Page count is captured while the `PDDocument` is still open; the `SourcePreview` record carries both the rendered first-page image and the page count back to the top of the pipeline so the PDF isn't reopened later.
|
||||
|
||||
## Future Direction
|
||||
|
||||
- If a postcard-specific "photo" chip is ever reintroduced, reuse `thumbnailAspect === 'LANDSCAPE' && pageCount === 1` rather than adding a new `kind` column.
|
||||
- If multi-size thumbnails are introduced (per ADR-004's future note), the aspect + pageCount are per-document and do not need to be duplicated per size.
|
||||
65
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
65
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
79
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
79
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal file
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal file
@@ -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<BilateralPair> {
|
||||
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<void> {
|
||||
// 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}`);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
48
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
48
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { thumbnailUrl } from '$lib/thumbnails';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||
pageCount?: number;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
|
||||
const url = $derived(thumbnailUrl(doc));
|
||||
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
||||
const pageCount = $derived(doc.pageCount ?? 1);
|
||||
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="conv-thumb-tile"
|
||||
data-aspect={aspect}
|
||||
class="relative {tileClass} flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||
>
|
||||
{#if url}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
data-testid="conv-thumb-skeleton"
|
||||
class="h-full w-full bg-line/60 motion-safe:animate-pulse"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if pageCount > 1}
|
||||
<span
|
||||
data-testid="conv-thumb-page-badge"
|
||||
class="absolute top-1 right-1 rounded-full bg-primary/90 px-2 py-1 text-sm leading-none font-bold text-surface"
|
||||
>{pageCount}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
113
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
113
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
60
frontend/src/lib/components/DistributionBar.svelte
Normal file
60
frontend/src/lib/components/DistributionBar.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
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);
|
||||
|
||||
const ariaLabel = $derived(m.dist_bar_aria({ outCount, senderName, inCount, receiverName }));
|
||||
const outSegmentText = $derived(m.dist_bar_segment({ count: outCount, name: shortSenderName }));
|
||||
const inSegmentText = $derived(m.dist_bar_segment({ count: inCount, name: shortReceiverName }));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div class="flex justify-between text-sm font-bold">
|
||||
<span class="inline-flex items-center gap-1 text-primary"
|
||||
>{outSegmentText}
|
||||
<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"
|
||||
>{inSegmentText}
|
||||
<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>
|
||||
71
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal file
71
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal file
@@ -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%');
|
||||
});
|
||||
});
|
||||
23
frontend/src/lib/components/TagChipList.svelte
Normal file
23
frontend/src/lib/components/TagChipList.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
let { tags, max = 3 }: { tags: Tag[]; max?: number } = $props();
|
||||
|
||||
const displayedTags = $derived(tags.slice(0, max));
|
||||
const hiddenTagCount = $derived(Math.max(0, tags.length - max));
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-1 pt-0.5">
|
||||
{#each displayedTags as tag (tag.id)}
|
||||
<span
|
||||
data-testid="thumb-row-tag"
|
||||
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
|
||||
>{tag.name}</span
|
||||
>
|
||||
{/each}
|
||||
{#if hiddenTagCount > 0}
|
||||
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
41
frontend/src/lib/components/TagChipList.svelte.spec.ts
Normal file
41
frontend/src/lib/components/TagChipList.svelte.spec.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
96
frontend/src/lib/components/ThumbnailRow.svelte
Normal file
96
frontend/src/lib/components/ThumbnailRow.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte';
|
||||
import TagChipList from '$lib/components/TagChipList.svelte';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { relativeYearsDe } from '$lib/relativeTime';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
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 = new Date()
|
||||
}: {
|
||||
doc: Doc;
|
||||
isOut: boolean;
|
||||
showOtherParty: boolean;
|
||||
now?: Date;
|
||||
} = $props();
|
||||
|
||||
const title = $derived(doc.title || doc.originalFilename);
|
||||
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) : ''
|
||||
);
|
||||
const directionLabel = $derived(isOut ? m.row_direction_sent() : m.row_direction_received());
|
||||
const ariaLabel = $derived(
|
||||
`${directionLabel}: ${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/documents/${doc.id}`}
|
||||
aria-label={ariaLabel}
|
||||
class="group flex min-h-[120px] items-start gap-3 border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 focus-within:bg-muted hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
|
||||
class:border-l-primary={isOut}
|
||||
class:border-l-accent={!isOut}
|
||||
>
|
||||
<ConversationThumbnail doc={doc} />
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<div class="min-w-0 flex-1 truncate text-sm font-bold text-ink">
|
||||
{title}
|
||||
</div>
|
||||
{#if relativeYearLabel}
|
||||
<div class="shrink-0 text-xs text-ink-3">{relativeYearLabel}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if doc.summary}
|
||||
<div class="line-clamp-2 text-sm text-ink-2 italic">
|
||||
“{doc.summary}”
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-[6px] gap-y-1 text-xs text-ink-3">
|
||||
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
||||
{#if doc.location}
|
||||
<span class="text-line">·</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
{#if otherPartyName}
|
||||
<span class="text-line">·</span>
|
||||
<span>{otherPartyName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<TagChipList tags={doc.tags ?? []} />
|
||||
</div>
|
||||
</a>
|
||||
208
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
208
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
@@ -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 <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');
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { relativeTimeDe } from './relativeTime';
|
||||
import { relativeTimeDe, relativeYearsDe } from './relativeTime';
|
||||
|
||||
const NOW = new Date('2026-04-20T12:00:00Z');
|
||||
|
||||
@@ -39,3 +39,31 @@ describe('relativeTimeDe', () => {
|
||||
expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeYearsDe', () => {
|
||||
it('returns singular "vor 1 Jahr" for exactly one whole year ago', () => {
|
||||
const from = new Date('2025-04-20T12:00:00Z');
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor 1 Jahr');
|
||||
});
|
||||
|
||||
it('returns plural "vor N Jahren" for more than one year', () => {
|
||||
const from = new Date('1940-04-20T12:00:00Z');
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor 86 Jahren');
|
||||
});
|
||||
|
||||
it('floors a partial year down (eleven months ago = 0 years)', () => {
|
||||
const from = new Date('2025-06-01T00:00:00Z');
|
||||
// We show "vor weniger als 1 Jahr" rather than rounding up to 1.
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor weniger als 1 Jahr');
|
||||
});
|
||||
|
||||
it('returns empty string when the input Date is invalid', () => {
|
||||
const invalid = new Date('not-a-real-date');
|
||||
expect(relativeYearsDe(invalid, NOW)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for future dates', () => {
|
||||
const future = new Date('2030-01-01T00:00:00Z');
|
||||
expect(relativeYearsDe(future, NOW)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,3 +9,22 @@ export function relativeTimeDe(from: Date, now: Date = new Date()): string {
|
||||
if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) });
|
||||
return m.comment_time_days({ count: Math.round(minutes / 1440) });
|
||||
}
|
||||
|
||||
// "vor N Jahren" for a historical letter date relative to now. Computed from
|
||||
// calendar fields (not a constant ms-per-year) so that a letter from exactly
|
||||
// one year ago reports "vor 1 Jahr" rather than falling on the wrong side of
|
||||
// a leap-year rounding. Returns "" for invalid or future dates — the caller
|
||||
// should then hide the relative-time label rather than render a misleading
|
||||
// "vor 0 Jahren".
|
||||
export function relativeYearsDe(from: Date, now: Date = new Date()): string {
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(now.getTime())) return '';
|
||||
if (from.getTime() > now.getTime()) return '';
|
||||
let years = now.getUTCFullYear() - from.getUTCFullYear();
|
||||
const beforeAnniversary =
|
||||
now.getUTCMonth() < from.getUTCMonth() ||
|
||||
(now.getUTCMonth() === from.getUTCMonth() && now.getUTCDate() < from.getUTCDate());
|
||||
if (beforeAnniversary) years -= 1;
|
||||
if (years < 1) return 'vor weniger als 1 Jahr';
|
||||
if (years === 1) return 'vor 1 Jahr';
|
||||
return `vor ${years} Jahren`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DistributionBar from '$lib/components/DistributionBar.svelte';
|
||||
import ThumbnailRow from '$lib/components/ThumbnailRow.svelte';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
interface Props {
|
||||
documents: {
|
||||
@@ -9,14 +13,15 @@ interface Props {
|
||||
originalFilename: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
sender?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
|
||||
summary?: string;
|
||||
contentType?: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||
pageCount?: number;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[];
|
||||
tags?: Tag[];
|
||||
}[];
|
||||
senderId: string;
|
||||
receiverId?: string;
|
||||
@@ -51,31 +56,9 @@ const countsByYear = $derived(
|
||||
|
||||
const outCount = $derived(documents.filter((d) => d.sender?.id === senderId).length);
|
||||
const inCount = $derived(documents.length - outCount);
|
||||
const outPct = $derived(documents.length > 0 ? (outCount / documents.length) * 100 : 0);
|
||||
|
||||
const isBilateral = $derived(!!senderId && !!receiverId);
|
||||
|
||||
const shortSenderName = $derived(senderName?.split(' ')[0] ?? senderName ?? '');
|
||||
const shortReceiverName = $derived(receiverName?.split(' ')[0] ?? receiverName ?? '');
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PLACEHOLDER: 'bg-brand-sand',
|
||||
UPLOADED: 'bg-brand-mint',
|
||||
TRANSCRIBED: 'bg-brand-mint',
|
||||
REVIEWED: 'bg-brand-navy/70',
|
||||
ARCHIVED: 'bg-brand-navy'
|
||||
};
|
||||
return map[status] ?? 'bg-brand-sand';
|
||||
}
|
||||
|
||||
function otherPartyName(doc: (typeof documents)[number]): string {
|
||||
if (doc.sender?.id === senderId) {
|
||||
const r = doc.receivers?.[0];
|
||||
return r ? r.displayName : m.conv_no_party();
|
||||
}
|
||||
return doc.sender ? doc.sender.displayName : m.conv_no_party();
|
||||
}
|
||||
const showOtherParty = $derived(!receiverId);
|
||||
|
||||
const newDocUrl = $derived(
|
||||
`/documents/new?senderId=${encodeURIComponent(senderId)}${receiverId ? `&receiverId=${encodeURIComponent(receiverId)}` : ''}`
|
||||
@@ -83,36 +66,12 @@ const newDocUrl = $derived(
|
||||
</script>
|
||||
|
||||
{#if isBilateral && documents.length > 0}
|
||||
<div
|
||||
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
|
||||
role="img"
|
||||
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
|
||||
>
|
||||
<div class="flex justify-between text-sm font-bold">
|
||||
<span class="inline-flex items-center gap-1 text-primary"
|
||||
>{outCount} von {shortSenderName}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="inline h-3.5 w-3.5 opacity-60"
|
||||
/></span
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 text-accent"
|
||||
>{inCount} von {shortReceiverName}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="inline h-3.5 w-3.5 opacity-60"
|
||||
/></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
|
||||
<div class="h-full bg-primary transition-all" style="width: {outPct}%"></div>
|
||||
<div class="h-full bg-accent transition-all" style="width: {100 - outPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<DistributionBar
|
||||
outCount={outCount}
|
||||
inCount={inCount}
|
||||
senderName={senderName ?? ''}
|
||||
receiverName={receiverName ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-hidden rounded-sm border border-line bg-surface">
|
||||
@@ -127,50 +86,7 @@ const newDocUrl = $derived(
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label="{doc.title || doc.originalFilename}, {doc.documentDate
|
||||
? formatDate(doc.documentDate)
|
||||
: ''}"
|
||||
class="group flex min-h-[44px] cursor-pointer items-center gap-[9px] border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-muted"
|
||||
class:border-l-primary={isOut}
|
||||
class:border-l-accent={!isOut}
|
||||
>
|
||||
<img
|
||||
src={isOut
|
||||
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
|
||||
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 shrink-0 opacity-60"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-[2px] truncate text-sm font-bold text-ink">
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
<div class="flex items-center gap-[5px] text-sm text-ink-3">
|
||||
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
||||
{#if doc.location}
|
||||
<span class="text-line">·</span>
|
||||
<span>{doc.location}</span>
|
||||
{/if}
|
||||
{#if !receiverId}
|
||||
<span class="text-line">·</span>
|
||||
<span>{otherPartyName(doc)}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="ml-[3px] h-[6px] w-[6px] shrink-0 rounded-full {statusDotClass(doc.status)}"
|
||||
title={doc.status}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 text-sm text-ink-3 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true">›</span
|
||||
>
|
||||
</a>
|
||||
<ThumbnailRow doc={doc} isOut={isOut} showOtherParty={showOtherParty} />
|
||||
{/each}
|
||||
|
||||
{#if canWrite}
|
||||
|
||||
@@ -30,6 +30,23 @@ const withPersons = {
|
||||
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
||||
};
|
||||
|
||||
const makePerson = (overrides: Record<string, unknown> = {}) => ({
|
||||
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<string, unknown> = {}) => ({
|
||||
id: 'd1',
|
||||
title: 'Testbrief',
|
||||
@@ -39,8 +56,15 @@ 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: 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', () => {
|
||||
|
||||
Reference in New Issue
Block a user