diff --git a/CLAUDE.md b/CLAUDE.md index 362baeac..8364399a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,7 +194,6 @@ frontend/src/routes/ │ ├── [id]/edit/ Person edit form │ ├── new/ Create person form │ └── review/ Triage view — confirm/rename/merge/delete provisional persons -├── briefwechsel/ Bilateral conversation timeline (Briefwechsel) ├── aktivitaeten/ Unified activity feed (Chronik) ├── geschichten/ Stories — list, [id], [id]/edit, new ├── stammbaum/ Family tree (Stammbaum) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java index 32fa0bc2..beb98256 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentController.java @@ -46,7 +46,6 @@ import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.document.DocumentVersionService; import org.raddatz.familienarchiv.filestorage.FileService; import org.raddatz.familienarchiv.user.UserService; -import org.springframework.data.domain.Sort; import org.springframework.security.core.Authentication; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -443,17 +442,6 @@ public class DocumentController { return documentVersionService.getVersion(id, versionId); } - @GetMapping("/conversation") - public List getConversation( - @RequestParam UUID senderId, - @RequestParam(required = false) UUID receiverId, - @RequestParam(required = false) LocalDate from, - @RequestParam(required = false) LocalDate to, - @RequestParam(defaultValue = "DESC") String dir) { - Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate"); - return documentService.getConversationFiltered(senderId, receiverId, from, to, sort); - } - private UUID requireUserId(Authentication authentication) { return SecurityUtils.requireUserId(authentication, userService); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index c28a8132..071f2276 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -15,7 +15,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.time.LocalDate; import java.util.Collection; import java.util.List; import java.util.Map; @@ -81,32 +80,6 @@ public interface DocumentRepository extends JpaRepository, JpaSp Optional findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort); - @EntityGraph("Document.full") - @Query("SELECT DISTINCT d FROM Document d " + - "JOIN d.receivers r " + - "WHERE " + - "((d.sender.id = :person1 AND r.id = :person2) " + - " OR " + - " (d.sender.id = :person2 AND r.id = :person1)) " + - "AND d.documentDate BETWEEN :from AND :to") - List findConversation( - @Param("person1") UUID person1, - @Param("person2") UUID person2, - @Param("from") LocalDate from, - @Param("to") LocalDate to, - Sort sort); - - @EntityGraph("Document.full") - @Query("SELECT DISTINCT d FROM Document d " + - "LEFT JOIN d.receivers r " + - "WHERE (d.sender.id = :personId OR r.id = :personId) " + - "AND d.documentDate BETWEEN :from AND :to") - List findSinglePersonCorrespondence( - @Param("personId") UUID personId, - @Param("from") LocalDate from, - @Param("to") LocalDate to, - Sort sort); - @Query(nativeQuery = true, value = """ SELECT d.id FROM documents d CROSS JOIN LATERAL ( diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 0aa824e5..66b10da0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -918,22 +918,6 @@ public class DocumentService { .orElse(""); } - // 2. SPEZIALITÄT: Der Schriftwechsel - // Findet alle Briefe ZWISCHEN zwei Personen (egal wer Sender/Empfänger war) - public List getConversation(UUID personA, UUID personB) { - - // Fall 1: A schreibt an B - Specification aToB = Specification.where(hasSender(personA)).and(hasReceiver(personB)); - - // Fall 2: B schreibt an A - Specification bToA = Specification.where(hasSender(personB)).and(hasReceiver(personA)); - - // Wir wollen (A->B) ODER (B->A) - Specification conversation = aToB.or(bToA); - - return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate")); - } - @Transactional public void updateScriptType(UUID documentId, ScriptType scriptType) { Document doc = getDocumentById(documentId); @@ -992,15 +976,6 @@ public class DocumentService { return documentRepository.findByReceiversId(receiverId); } - public List getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) { - LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01"); - LocalDate dateTo = (to != null) ? to : LocalDate.now(); - if (receiverId == null) { - return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort); - } - return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort); - } - public long getIncompleteCount() { return documentRepository.countByMetadataCompleteFalse(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java index 05a69895..234abbd8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java @@ -262,67 +262,6 @@ class DocumentRepositoryTest { assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete()); } - // ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ──── - - @Test - void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() { - Person sender = personRepository.save(Person.builder() - .firstName("Hans").lastName("Müller").build()); - Person receiver1 = personRepository.save(Person.builder() - .firstName("Anna").lastName("Schmidt").build()); - Person receiver2 = personRepository.save(Person.builder() - .firstName("Bertha").lastName("Wagner").build()); - Person receiver3 = personRepository.save(Person.builder() - .firstName("Clara").lastName("Koch").build()); - - // Document addressed to all three receivers - Document doc = documentRepository.save(Document.builder() - .title("Rundschreiben") - .originalFilename("rundschreiben.pdf") - .status(DocumentStatus.UPLOADED) - .sender(sender) - .receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3))) - .documentDate(LocalDate.of(1950, 6, 1)) - .build()); - - Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); - LocalDate from = LocalDate.of(1900, 1, 1); - LocalDate to = LocalDate.of(2000, 1, 1); - - // Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result - List results = documentRepository.findSinglePersonCorrespondence( - receiver1.getId(), from, to, sort); - - assertThat(results).hasSize(1); - assertThat(results.get(0).getId()).isEqualTo(doc.getId()); - } - - @Test - void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() { - Person sender = personRepository.save(Person.builder() - .firstName("Hans").lastName("Müller").build()); - Person receiver = personRepository.save(Person.builder() - .firstName("Anna").lastName("Schmidt").build()); - - documentRepository.save(Document.builder() - .title("Brief als Absender") - .originalFilename("brief_absender.pdf") - .status(DocumentStatus.UPLOADED) - .sender(sender) - .receivers(new HashSet<>(Set.of(receiver))) - .documentDate(LocalDate.of(1950, 6, 1)) - .build()); - - Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); - LocalDate from = LocalDate.of(1900, 1, 1); - LocalDate to = LocalDate.of(2000, 1, 1); - - List results = documentRepository.findSinglePersonCorrespondence( - sender.getId(), from, to, sort); - - assertThat(results).hasSize(1); - } - // ─── findSegmentationQueue ──────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 0ed058a8..a1ff86bf 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -1128,53 +1128,6 @@ class DocumentServiceTest { .isEqualTo("19650332_Mueller_Hans"); } - // ─── getConversationFiltered ─────────────────────────────────────────────── - - @Test - void getConversationFiltered_passesGivenDates_whenFromAndToAreProvided() { - UUID senderId = UUID.randomUUID(); - UUID receiverId = UUID.randomUUID(); - LocalDate from = LocalDate.of(1940, 1, 1); - LocalDate to = LocalDate.of(1960, 12, 31); - Sort sort = Sort.by(Sort.Direction.ASC, "documentDate"); - when(documentRepository.findConversation(senderId, receiverId, from, to, sort)) - .thenReturn(List.of()); - - documentService.getConversationFiltered(senderId, receiverId, from, to, sort); - - verify(documentRepository).findConversation(senderId, receiverId, from, to, sort); - } - - @Test - void getConversationFiltered_usesMinDateForFrom_whenFromIsNull() { - UUID senderId = UUID.randomUUID(); - UUID receiverId = UUID.randomUUID(); - Sort sort = Sort.by(Sort.Direction.ASC, "documentDate"); - when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort))) - .thenReturn(List.of()); - - documentService.getConversationFiltered(senderId, receiverId, null, null, sort); - - ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDate.class); - verify(documentRepository).findConversation(eq(senderId), eq(receiverId), fromCaptor.capture(), any(LocalDate.class), eq(sort)); - assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.parse("0000-01-01")); - } - - @Test - void getConversationFiltered_usesTodayForTo_whenToIsNull() { - UUID senderId = UUID.randomUUID(); - UUID receiverId = UUID.randomUUID(); - Sort sort = Sort.by(Sort.Direction.ASC, "documentDate"); - when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort))) - .thenReturn(List.of()); - - documentService.getConversationFiltered(senderId, receiverId, null, null, sort); - - ArgumentCaptor toCaptor = ArgumentCaptor.forClass(LocalDate.class); - verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), toCaptor.capture(), eq(sort)); - assertThat(toCaptor.getValue()).isEqualTo(LocalDate.now()); - } - // ─── updateDocumentTags — empty tag in list ─────────────────────────────── @Test @@ -1760,35 +1713,6 @@ class DocumentServiceTest { .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt")); } - // ─── getConversationFiltered (single-person mode) ───────────────────────── - - @Test - void getConversationFiltered_callsSinglePersonQuery_whenReceiverIdIsNull() { - UUID personId = UUID.randomUUID(); - Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); - when(documentRepository.findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort))) - .thenReturn(List.of()); - - documentService.getConversationFiltered(personId, null, null, null, sort); - - verify(documentRepository).findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort)); - verify(documentRepository, never()).findConversation(any(), any(), any(), any(), any()); - } - - @Test - void getConversationFiltered_callsBilateralQuery_whenReceiverIdIsSet() { - UUID senderId = UUID.randomUUID(); - UUID receiverId = UUID.randomUUID(); - Sort sort = Sort.by(Sort.Direction.DESC, "documentDate"); - when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort))) - .thenReturn(List.of()); - - documentService.getConversationFiltered(senderId, receiverId, null, null, sort); - - verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)); - verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any()); - } - // ─── searchDocuments — SENDER sort includes documents with null sender ───── @Test diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5bc46261..1b54eacf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,8 +48,6 @@ Both stacks are organised **package-by-domain**: each domain owns its entities, A **derived domain** has its own routes and UI but no database tables of its own; it is assembled from data owned by Tier-1 domains. -**`conversation`** (route: `/briefwechsel`) — bilateral letter timeline between two `Person`s. Derived from `Document` sender/receiver relationships. The `DocumentRepository` bidirectional query is the only data source. - **`activity`** (route: `/aktivitaeten`) — family activity feed. Derived from `audit_log`, `notifications`, and document events. No aggregation table; computed on-the-fly by `DashboardService` and composed in the SvelteKit load function. --- diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 2c0f3aba..0632fa6e 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -139,9 +139,6 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence **Aktivität / Aktivitäten** `[user-facing]` — the family activity feed accessible at `/aktivitaeten`. Shows recent documents, transcriptions, comments, and Geschichten as a chronological timeline. _See also [Chronik](#chronik-internal)._ -**Briefwechsel** `[user-facing]` — the bilateral conversation timeline between two `Person`s, derived from `Document` sender/receiver relationships. Accessible at `/briefwechsel`. Not a persistent entity — data is computed from existing `Document` records. -_See also [Derived domain](#derived-domain)._ - **Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing). **Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission. @@ -156,8 +153,7 @@ _See also [Derived domain](#derived-domain)._ **Cross-cutting** — code that lives in `lib/shared/` (frontend) or cross-domain packages (backend) because it has no entity of its own, no user-facing CRUD, AND is used by two or more domains OR is framework infrastructure (error handling, API client, i18n utilities). -**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. Current derived domains: `conversation` (from `Document` sender/receivers) and `activity` (from audit, notifications, document events). -_See also [Briefwechsel](#briefwechsel-user-facing)._ +**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. The current derived domain is `activity` (from audit, notifications, document events). **Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`. diff --git a/docs/adr/030-briefwechsel-removal-unidirectional-search.md b/docs/adr/030-briefwechsel-removal-unidirectional-search.md new file mode 100644 index 00000000..7362ac05 --- /dev/null +++ b/docs/adr/030-briefwechsel-removal-unidirectional-search.md @@ -0,0 +1,54 @@ +# ADR-030 — Removing Briefwechsel trades bidirectional correspondence for a unidirectional search filter + +**Date:** 2026-06-02 +**Status:** Accepted +**Issue:** #716 (remove the Briefwechsel view; retarget its links to document search) +**Milestone:** — + +--- + +## Context + +The standalone **Briefwechsel** view (`/briefwechsel`) was a bilateral letter timeline +between two `Person`s. It was not in the main navigation; its only inbound product link +was the "Häufige Korrespondenten" card on a person's detail page. It was backed by a +dedicated endpoint (`GET /api/documents/conversation`) and two repository queries +(`findConversation`, `findSinglePersonCorrespondence`) that nothing else used. + +The view's data source, `findConversation`, was **bidirectional**: it returned letters +where `(sender = A AND receiver = B) OR (sender = B AND receiver = A)` — i.e. the +exchange in both directions. We removed the view entirely (frontend and backend) and +retargeted the one inbound link into the existing **document search** +(`/documents?senderId=A&receiverId=B`). + +Document search composes its `senderId`/`receiverId` filters with AND +(`sender.id = A` **AND** `receivers contains B`), so the retargeted link shows **only the +A→B direction**. The reverse direction (B's replies to A) is no longer surfaced by +clicking a correspondent. + +## Decision + +**Accept the behaviour change: the retargeted card link is unidirectional (A→B only).** +The reverse direction is intentionally dropped rather than preserved with a redirect +shim or a new bidirectional search filter. + +- The card link sets both params (`senderId=A&receiverId=B`); the destination is the + consistent, already-tested document search rather than a separate dedicated view. +- The "×N" badge on each correspondent chip remains **bilateral** — it counts shared + letters in both directions as a relationship-strength signal — so the badge may exceed + the unidirectional search result count. This is surfaced in the badge's title, not + recomputed. + +## Consequences + +- **Regression:** a reader can no longer reach B→A replies in one click from the + correspondents card. They must run a second search with sender/receiver swapped. +- The bilateral query code (`findConversation`, `findSinglePersonCorrespondence`, the + `/api/documents/conversation` endpoint, and the `getConversationFiltered` service + method) is fully removed — no dormant dead code. +- No data migration and no schema change: only query/endpoint code was removed; the + `documents`, `persons` and join tables are untouched. The `hasSender`/`hasReceiver` + specifications stay — document search still uses them. +- **Future enhancement (out of scope here):** a bidirectional "between these two people, + either direction" document-search filter would restore the dropped direction without + reviving the standalone view. If built, it supersedes the unidirectional link. diff --git a/docs/architecture/c4-diagrams.md b/docs/architecture/c4-diagrams.md index d01cdfaa..59eaa220 100644 --- a/docs/architecture/c4-diagrams.md +++ b/docs/architecture/c4-diagrams.md @@ -104,7 +104,7 @@ C4Component ContainerDb(minio, "MinIO") System_Boundary(backend, "API Backend (Spring Boot)") { - Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.") + Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, and batch metadata updates.") Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state via GET /api/admin/import-status (IDLE/RUNNING/DONE/FAILED).") Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.") @@ -112,7 +112,7 @@ C4Component Component(importOrch, "CanonicalImportOrchestrator", "Spring Service — @Async", "Runs four idempotent loaders (TagTree → PersonRegister → PersonTree → Document) in a fixed DAG over the normalizer's committed canonical artifacts (canonical-*.xlsx + canonical-persons-tree.json) from /import — see diagram 3b. Owns the IDLE/RUNNING/DONE/FAILED state machine.") Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.") - Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.") + Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, full-text search with ranking and match highlighting, and transcription pipeline queue projections.") Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).") } @@ -442,7 +442,7 @@ C4Component ### 3c — People, Stories & Discovery -Person directory, bilateral conversations, activity feed, stories, family tree, and user profiles. +Person directory, activity feed, stories, family tree, and user profiles. ```mermaid C4Component @@ -454,7 +454,6 @@ C4Component System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.") Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.") - Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.") Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.") Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.") @@ -466,7 +465,6 @@ C4Component Rel(user, personsPage, "Browses family members", "HTTPS / Browser") Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON") Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON") - Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON") Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON") Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON") diff --git a/docs/architecture/c4/l3-backend-3b-document-management.puml b/docs/architecture/c4/l3-backend-3b-document-management.puml index ac2f0208..65049e7e 100644 --- a/docs/architecture/c4/l3-backend-3b-document-management.puml +++ b/docs/architecture/c4/l3-backend-3b-document-management.puml @@ -8,7 +8,7 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16") ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)") System_Boundary(backend, "API Backend (Spring Boot)") { - Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, batch metadata updates, and per-month density aggregation for the timeline filter widget.") + Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, batch metadata updates, and per-month density aggregation for the timeline filter widget.") Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).") Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.") Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.") @@ -20,7 +20,7 @@ System_Boundary(backend, "API Backend (Spring Boot)") { Component(titleFmt, "DocumentTitleFormatter", "Pure helper", "Formats the date label baked into an import title at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).") Component(sheetReader, "CanonicalSheetReader", "POI helper", "Maps a canonical .xlsx by header name (no positional indices), splits pipe-delimited list columns, fails closed (IMPORT_ARTIFACT_INVALID) on a missing required header.") Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.") - Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.") + Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, full-text search with ranking and match highlighting, and transcription pipeline queue projections.") Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).") } diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml index b64539ab..a73ecc4a 100644 --- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml +++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml @@ -10,7 +10,6 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory (server-side filtered + paginated) and detail. Directory: type/family/has-documents chips, reader default (familyMember OR documentCount > 0), writer-only show-all toggle. Detail: metadata, document list sent/received, correspondents, family relationships.") Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.") Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.") - Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.") Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.") Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.") @@ -24,7 +23,6 @@ Rel(user, personsPage, "Browses family members", "HTTPS / Browser") Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearchResult), GET /api/persons/{id}", "HTTP / JSON") Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON") Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON") -Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON") Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON") Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON") diff --git a/docs/specs/briefwechsel-thumbnail-rows-spec.html b/docs/specs/briefwechsel-thumbnail-rows-spec.html deleted file mode 100644 index 00d79449..00000000 --- a/docs/specs/briefwechsel-thumbnail-rows-spec.html +++ /dev/null @@ -1,1073 +0,0 @@ - - - - - -Briefwechsel — Thumbnail Rows · Final Design Spec · Familienarchiv - - - -
- - -
-
-
-

Briefwechsel — Thumbnail Rows

-

Final row design for /briefwechsel. PDF thumbnail anchors each row; summary reads as a quote; no status lifecycle, no script-type indicator. Designed for fun discovery, not dense scanning. Scales from 320 px mobile to 1440 px desktop, light and dark. Serves both the millennial audience (25–42) and the senior family audience (60 +) — the senior constraint drives touch targets, line height, and summary legibility.

-
-
FINAL
-
-
-
Route
/briefwechsel · list surface
-
Row height (desktop)
128 px · comfortable
-
Thumbnail
82×106 portrait · 104×72 landscape
-
Removed
status dot · script type · archive box
-
-
- -
- Reading this spec. Mockups in Section 02 are scaled to ~55 % of real pixel values so that multiple viewports fit on one page. Never copy pixel sizes from the mockups. Use the impl-ref tables for exact Tailwind class + pixel value. Close-ups in Section 03 are rendered at ~100 % scale for pixel-accurate reference. -
- - -
-
Inhalt
-
    -
  1. 01 Page anatomy default · 1440 px
  2. -
  3. 02 Content states × 3 viewports 5 states · 15 frames
  4. -
  5. 03 Row anatomy close-ups 4 row types @ real size
  6. -
  7. 04 Distribution bar bilateral mode only
  8. -
  9. 05 Accessibility contract WCAG AA/AAA
  10. -
  11. 06 Implementation notes data · thumbnails · routing
  12. -
-
- - -
-

01Page Anatomy — Default State at 1440 px (single-person)

-

The page is a single vertical column (max-w-7xl). Filter card sticks to the top of the content region; the row list starts immediately below, grouped by year dividers. All viewports render the same regions in the same order — they only adapt spacing and thumbnail size, never rearrange.

- -
-
-
-
familienarchiv.de/briefwechsel?senderId=…
-
-
- -
DokumentePersonenBriefwechselChronik
-
-
- -
-
-
Person
Walter de Gruyter
-
Korrespondent — optional
Alle Korrespondenten
-
-
-
Newest ↓
-
▾ Filter
-
851 Briefe
-
-
📋 Alle Briefe von Walter de Gruyter — wähle einen Korrespondenten oben um einzugrenzen
-
- -
19401 Brief
- -
-
-
-
-
Demo leserlicher Brief
-
letzte Lebenstage von W. Dörpfeld in Griechenland — ausführlicher Bericht aus Belgard
-
← eingehendGertrud von Rofden·📍 Belgard·DörpfeldGriechenland
-
-
31. Mai 1940
vor 85 Jahren
-
-
- -
19235 Briefe
-
-
-
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
-
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·VerlagFamilie
-
-
2. September 1923
vor 102 Jahren
-
-
-
-
-
Ansichtskarte – 2. September 1923 – B.Lichterfelde
-
kurze Grüße aus B.Lichterfelde, Hinweis auf den kommenden Besuch
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·✉ Postkarte
-
-
2. September 1923
vor 102 Jahren
-
-
-
4 S.
-
-
W-0524 – 31. Juli 1923 – Berlin
-
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den Umzug
-
→ ausgehendan Walter Dieckmann·📍 Berlin·Geburtstag
-
-
31. Juli 1923
vor 102 Jahren
-
-
-
-
-
-
-
-
A · Filter card
Two inputs (person required, correspondent optional) + action row + hint bar. Uses bg-surface wrapper, not a card — the hint bar gives it closure.
-
B · Year divider
Sticky-looking band between year groups. Large navy numeral + brief count. Uses bg-muted and a 1 px rule above/below.
-
C · Row list
Single <ul> per year group. Each row is an <a> with role="listitem" ancestor. Border-left accent colors direction: navy = outgoing, mint-darker = incoming.
-
Row · Thumbnail cell
Fixed 104 × 120 px cell on desktop; portrait and landscape both centered in the same cell so row height stays consistent across mixed media.
-
Row · Body
Serif title · italic serif summary (with mint quote glyphs) · sans meta line with direction + counterpart + location + tags. Summary omitted entirely when empty.
-
Row · Right column
Date (serif, bold) + relative age ("vor 102 Jahren"). No status, no archive location — deliberately calm.
-
-
- -
-
Implementation Reference — Page ShellTailwind 4 · tokens from layout.css
- - - - - - - - - - - - -
ElementClassesRealNote
Page containermx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8max 80remMatches production /briefwechsel
Filter card wrappermb-8 rounded-sm border border-line bg-surface p-6 shadow-smpadding 24 pxExisting CorrespondenzPersonBar container
Year dividerflex items-baseline gap-3 border-y border-line bg-muted px-[14px] py-[8px]border 1 px both sidesKeep production styling — only row changes
Year numeralfont-serif text-2xl font-black tracking-tight text-primary24 px / 900 / -0.025emMerriweather Black
Year counttext-sm font-bold text-ink-314 px / 700"5 Briefe" / Paraglide plural
Row list wrapperoverflow-hidden rounded-sm border border-line bg-surface1 px borderHides row borders at ends
Rowgroup grid grid-cols-[104px_1fr_auto] gap-5 items-center px-5 py-[14px] border-b border-line-2 border-l-[3px] min-h-[128px] cursor-pointer transition-colors hover:bg-muted128 px min · 20 × 14 paddingborder-l-primary out · border-l-accent in
Touch targetFull row is clickable; row height 128 px > WCAG 44 px minimum × ~3128 ≥ 44Senior audience: comfort over density
-
-
- - -
-

02Content States × 3 Viewports

-

- Five states covering the combinations that matter. Every frame renders the full page shell (header → filter card → list). Reading order per state: 320 px (mobile S) → 768 px (tablet) → 1440 px (desktop). Watch for filter card wrap at 320, thumbnail shrinkage, and the right-column behaviour under content pressure. -

- - -
-
01Default · Single person with mixed row types
-
The happy path. Four rows shown: incoming typed letter, outgoing handwritten letter, outgoing postcard (landscape thumbnail), outgoing multi-page letter (page badge). Summaries present on three of four — the fourth row shows the clean no-summary variant.
-
- -
- 320 px · Mobile176 px @ 55% -
-
9:41
-
-
Briefwechsel
-
-
Person
Walter de Gruyter
Korrespondent
alle
851
-
19235
-
-
W-0397
Elsbeths Kommentar
H. Cram
2. Sep
-
Ansichtskarte
H. Cram
2. Sep
-
4
W-0524
Geburtstag & Umzug
W. Dieckmann
31. Jul
-
W-0396
H. Cram
2. Sep
-
-
-
-
-
- -
- 768 px · Tablet422 px @ 55% -
-
familienarchiv.de/briefwechsel
-
-
DokumenteBriefwechsel
-
-
Person
Walter de Gruyter
Korrespondent — optional
Alle Korrespondenten
Newest ↓
▾ Filter
851 Briefe
📋 Alle Briefe von Walter de Gruyter
-
19235 Briefe
-
-
W-0397 – 2. September 1923
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
an Herbert CramVerlag
2. Sep 1923
vor 102 J.
-
Ansichtskarte – 2. September 1923
kurze Grüße aus B.Lichterfelde
an Herbert Cram✉ Postkarte
2. Sep 1923
vor 102 J.
-
4 S.
W-0524 – 31. Juli 1923 – Berlin
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag
an Walter DieckmannGeburtstag
31. Jul 1923
vor 102 J.
-
W-0396 – 2. September 1923
an Herbert Cram
2. Sep 1923
vor 102 J.
-
-
-
-
-
- -
- 1440 px · Desktop720 px @ 55% -
-
familienarchiv.de/briefwechsel?senderId=…
-
-
DokumentePersonenBriefwechselChronik
-
-
Person
Walter de Gruyter
Korrespondent — optional
Alle Korrespondenten
Newest ↓
▾ Filter
851 Briefe
📋 Alle Briefe von Walter de Gruyter — wähle einen Korrespondenten oben um einzugrenzen
-
19235 Briefe
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·VerlagFamilie
2. September 1923
vor 102 Jahren
-
Ansichtskarte – 2. September 1923 – B.Lichterfelde
kurze Grüße aus B.Lichterfelde, Hinweis auf den kommenden Besuch
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·✉ Postkarte
2. September 1923
vor 102 Jahren
-
4 S.
W-0524 – 31. Juli 1923 – Berlin
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den Umzug
→ ausgehendan Walter Dieckmann·📍 Berlin·GeburtstagVerlag
31. Juli 1923
vor 102 Jahren
-
W-0396 – 2. September 1923 – B.Lichterfelde
→ ausgehendan Herbert Cram·📍 B.Lichterfelde
2. September 1923
vor 102 Jahren
-
-
-
-
-
-
-
Layout-Beobachtungen. -
    -
  • 320 px: Filter card collapses to a single column. Title truncates with ellipsis (W-0397), summary keeps 1 line max; counterpart shortens to initials+last (H. Cram). Date format is 2. Sep — no year (year dividers provide it).
  • -
  • 768 px: Two-column filter returns. Title shows full label; summary gets 2 lines; date is 2. Sep 1923; location meta omitted (kept to 2 items), tags trimmed to one.
  • -
  • 1440 px: Full meta (direction word, counterpart, location, 2 tags). Relative date appears below the absolute date.
  • -
  • Row 4 (no summary) retains the exact same row height as others — the row grid is min-h-[128px] at desktop so mixed-summary lists don't visually jump.
  • -
-
-
- - -
-
02Bilateral · Both filters set + distribution bar
-
Sender and receiver both selected. A distribution bar appears above the row list, pattern lifted from production ConversationTimeline. Rows show compact direction glyph instead of the word — the bar above already established direction semantics.
-
-
- 320 px · Mobile176 px @ 55% -
-
9:41
-
-
Briefwechsel
-
-
Person
Walter
Korrespondent
Herbert
143
-
87 Walter →← 56 Herbert
-
-
W-0397
Elsbeths Kommentar
B.Lichterfelde
2. Sep
-
H-0213
Antwort zur Herbstlieferung
Leipzig
29. Aug
-
Ansichtskarte
Thür. Wald
20. Aug
-
-
-
-
-
-
- 768 px · Tablet422 px @ 55% -
-
…/briefwechsel?senderId=&receiverId=
-
-
DokumenteBriefwechsel
-
-
Person
Walter de Gruyter
Korrespondent
Herbert Cram
⇄ Tauschen
Newest ↓
▾ Filter
143 Briefe
-
87 von Walter de Gruyter →← 56 von Herbert Cram
-
-
W-0397 – 2. September 1923
von Elsbeth geschriebener Kommentar
Walter an HerbertVerlag
2. Sep 1923
vor 102 J.
-
H-0213 – 29. August 1923 – Leipzig
Antwort auf Walters Anfrage zur Herbstauslieferung
Herbert an WalterVerlag
29. Aug 1923
vor 102 J.
-
Ansichtskarte – 20. August 1923
Urlaubsgruß aus Thüringen
Herbert an Walter✉ Postkarte
20. Aug 1923
vor 102 J.
-
-
-
-
-
-
- 1440 px · Desktop720 px @ 55% -
-
familienarchiv.de/briefwechsel?senderId=…&receiverId=…
-
-
DokumentePersonenBriefwechselChronik
-
-
Person
Walter de Gruyter
Korrespondent
Herbert Cram
⇄ Tauschen
Newest ↓
▾ Filter
143 Briefe im Zeitraum
- -
-
W-0397 – 2. September 1923 – B.Lichterfelde
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
Walter an Herbert·📍 B.Lichterfelde·Verlag
2. September 1923
vor 102 Jahren
-
H-0213 – 29. August 1923 – Leipzig
Antwort auf Walters Anfrage zur Herbstauslieferung
Herbert an Walter·📍 Leipzig·Verlag
29. August 1923
vor 102 Jahren
-
Ansichtskarte – 20. August 1923 – Thüringer Wald
Urlaubsgruß, kurze Notiz über Wetter und geplante Rückkehr
Herbert an Walter·📍 Thüringer Wald·✉ Postkarte
20. August 1923
vor 102 Jahren
-
-
-
-
-
-
-
Distribution bar — only renders when both senderId and receiverId are set. -
    -
  • Labels are right/left-aligned matching the bar direction (out on left, in on right). Bar widths come from backend-calculated counts, not percentages on the client.
  • -
  • role="img" with a descriptive aria-label — screen readers hear the full distribution in one sentence.
  • -
  • Below 320 px: labels stack vertically with a 4 px gap. Never truncate a count.
  • -
  • In meta line, direction word collapses to glyph ("→ / ←") because the distribution bar above has already named the parties.
  • -
-
-
- - -
-
03Loading · Skeleton (all three viewports render the same pattern)
-
SSR renders without thumbnails. While thumbnails are fetching, show a paper-coloured skeleton in the thumbnail cell. Title, summary and meta remain as normal text (the data is already present). No spinner, no pulse on the text — only the thumbnail shimmers.
-
-
- 320 px · Mobile176 px @ 55% -
-
9:41
-
-
-
-
Person
Walter
Korresp.
alle
851
-
19235
-
-
W-0397
Elsbeths Kommentar
H. Cram
2. Sep
-
W-0396
H. Cram
2. Sep
-
-
-
-
-
-
- 768 px · Tablet422 px @ 55% -
-
…/briefwechsel
-
-
-
-
Person
Walter de Gruyter
Korresp.
Alle
Newest ↓
851 Briefe
-
19235 Briefe
-
-
W-0397 – 2. September 1923
Elsbeths Kommentar
Herbert Cram
2. Sep 1923
-
W-0396 – 2. September 1923
Herbert Cram
2. Sep 1923
-
-
-
-
-
-
- 1440 px · Desktop720 px @ 55% -
-
familienarchiv.de/briefwechsel
-
-
-
-
Person
Walter de Gruyter
Korrespondent
Alle
Newest ↓
▾ Filter
851 Briefe
-
19235 Briefe
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte
→ ausgehendan Herbert Cram
2. September 1923
-
W-0396 – 2. September 1923 – B.Lichterfelde
→ ausgehendan Herbert Cram
2. September 1923
-
-
-
-
-
-
-
- - -
-
04Empty · No results matching current filters
-
Filter combination returns zero letters. Empty card sits below the filter card. Primary use case: date range that excludes all letters. Message gives the user a clear reset path.
-
-
- 320 px · Mobile176 px @ 55% -
-
9:41
-
-
-
-
Person
Walter
Korresp.
alle
0
-
Keine Briefe
Für diesen Filter gibt es keine Einträge. Zeitraum anpassen oder Filter zurücksetzen.
-
-
-
-
-
- 768 px · Tablet422 px @ 55% -
-
…/briefwechsel?from=1950&to=1960
-
-
-
-
Person
Walter de Gruyter
Korresp.
Alle
Newest ↓
▾ Filter
0 Briefe
-
Keine Briefe in diesem Zeitraum
Von 1950 bis 1960 gibt es keine Korrespondenz. Zeitraum erweitern oder Filter zurücksetzen.
-
-
-
-
-
- 1440 px · Desktop720 px @ 55% -
-
familienarchiv.de/briefwechsel?from=1950&to=1960
-
-
-
-
Person
Walter de Gruyter
Korrespondent
Alle
Newest ↓
▾ Filter
0 Briefe
-
Keine Briefe in diesem Zeitraum
Von 1950 bis 1960 gibt es keine Korrespondenz mit Walter de Gruyter. Passe den Zeitraum an oder setze die Filter zurück.
-
-
-
-
-
-
- - -
-
05Single-person hint — reminder to narrow
-
Already shown in production. Stays exactly as is. Re-rendered here so developers confirm it still renders above the first year divider when only senderId is set. Not shown in bilateral mode.
-
Kein Redesign. Die bestehende SinglePersonHintBar.svelte bleibt unverändert und rendert zwischen Filter-Card und erster Jahres-Trennlinie. Nur in Single-Person-Modus, nicht bilateral.
-
- -
-
Implementation Reference — Content Stateslist rendering + skeleton
- - - - - - - - - - - -
ElementClassesRealNote
Skeleton thumbanimate-pulse bg-gradient-to-r from-[#f5f4ef] via-[#eceae4] to-[#f5f4ef] rounded-[1px]shimmer 1.4 sApplied only to .bw-thumb, never to text
Empty cardflex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-smpadding 96 px yMatches production empty state
Empty titlefont-serif text-ink18 px desktopParaglide: m.conv_no_results_heading()
Empty bodymt-2 text-sm text-ink-3 max-w-prose mx-auto14 pxParaglide: m.conv_no_results_text()
Distribution barflex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2role="img"aria-label: "Briefverteilung: X von A, Y von B"
Distbar labelsflex justify-between text-sm font-bold · .out text-primary · .in text-accent14 px / 700Counts in tabular-nums
Distbar barflex h-[5px] overflow-hidden rounded-full bg-line5 pxSegments animated with transition-[width]
-
-
- - -
-

03Row Anatomy · Close-Ups at ~100% Scale

-

Four row types at near-real pixel sizes. These are the reference renderings developers check against when implementing ConversationTimeline.svelte (or its successor ThumbnailRow.svelte).

- - -
-
Type A · Portrait letter with summary + tags
-
-
-
-
W-0397 – 2. September 1923 – B.Lichterfelde
-
von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte — Notiz auf der Rückseite
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde·VerlagFamilie
-
-
2. September 1923
vor 102 Jahren
-
-
-
Type A — Portrait Letter with Summaryrendered from Document + thumbnail URL
- - - - - - - - - - - - - - - -
PartClassesRealNote
Row containergroup grid grid-cols-[104px_1fr_auto] gap-5 items-center px-5 py-[14px] min-h-[128px] border-b border-line-2 border-l-[3px] border-l-primary transition-colors hover:bg-muted128 px min<a href="/documents/{id}"> · keyboard reachable
Thumbnail cellw-[104px] h-[120px] flex items-center justify-center shrink-0104 × 120Centers any aspect ratio
Thumbnail imgw-[82px] h-[106px] rounded-[1px] shadow-sm ring-1 ring-white/80 transition-transform group-hover:-translate-y-[1px] group-hover:shadow-md82 × 106 portraitloading="lazy" · alt="" (decorative, title covers meaning)
Titlefont-serif text-base font-bold text-ink leading-[1.35] truncate16 px / 700Merriweather Bold
Summaryfont-serif italic text-sm text-ink-2 leading-[1.55] line-clamp-214 px italicOmit element entirely when doc.summary is empty — no placeholder
Summary quote marks::before & ::after pseudos, color text-accent22 px„…" (German curly quotes)
Meta rowmt-0.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-3 items-center12 pxSeparators use · with text-line
Direction chiptext-[13px] font-extrabold text-primary (out) · text-accent (in)13 px / 800"→ ausgehend" / "← eingehend" (word omitted in bilateral mode)
Tag chipinline-flex items-center text-[10px] font-bold bg-accent/80 text-primary px-[7px] py-0.5 rounded-full10 px / 700Max 2 tags visible at 1440; 1 at 768; 0 at 320
Right column — datefont-serif text-sm font-bold text-ink-2 whitespace-nowrap text-right14 px / 700Intl.DateTimeFormat de-DE (see CLAUDE.md)
Right column — relativetext-[10px] text-ink-3 font-semibold10 px"vor X Jahren" — calculated client-side
-
-
- - -
-
Type B · Portrait letter without summary (clean variant)
-
-
-
-
W-0396 – 2. September 1923 – B.Lichterfelde
-
→ ausgehendan Herbert Cram·📍 B.Lichterfelde
-
-
2. September 1923
vor 102 Jahren
-
-
No placeholder when summary is missing. The summary element is not rendered at all — row height still hits min-h-[128px] so the list stays rhythmic. Tags are also omitted when empty (no empty chip row).
-
- - -
-
Type C · Postcard · landscape thumbnail with stamp + postmark
-
-
-
-
Ansichtskarte – 20. August 1923 – Thüringer Wald
-
Urlaubsgruß, kurze Notiz über Wetter und geplante Rückkehr
-
← eingehendvon Herbert Cram·📍 Thüringer Wald·✉ Postkarte
-
-
20. August 1923
vor 102 Jahren
-
-
-
Type C — Postcard (landscape)aspect ratio detection + kind chip
- - - - - - - -
PartClassesRealNote
Thumbnailw-[104px] h-[72px] rounded-[1px] shadow-sm ring-1 ring-white/80104 × 72 landscapeAspect ratio detected server-side from PDF page 1 dimensions (w/h > 1.1 → landscape)
Kind chipinline-flex items-center text-[10px] font-bold uppercase tracking-wide bg-line text-ink-2 px-[7px] py-0.5 rounded-full10 px / 700 uppercaseParaglide: m.doc_kind_postcard() — shown only when thumbnail is landscape
Stamp cornerCSS pseudo-element on thumbnail — 16×18 px gradient square top-right 5 pxdecorativeIn production: rendered by the thumbnail service as part of the real scan; the CSS is only for spec rendering
-
-
- - -
-
Type D · Multi-page letter with "N Seiten" badge
-
-
-
- 4 S. -
-
-
W-0524 – 31. Juli 1923 – Berlin
-
Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den anstehenden Umzug nach B.Lichterfelde
-
→ ausgehendan Walter Dieckmann·📍 Berlin·GeburtstagVerlag
-
-
31. Juli 1923
vor 102 Jahren
-
-
-
Type D — Page-count Badgeonly when pages > 1
- - - - - - - -
PartClassesRealNote
Badge containerabsolute top-1 -right-1 bg-primary text-primary-fg text-[10px] font-bold px-[7px] py-0.5 rounded-full ring-2 ring-white10 px / 700Overlaps the thumbnail by 4 px right
LabelParaglide: m.doc_pages_count({ count })"4 S."Abbreviated form for the badge; full "4 Seiten" appears in the document detail page
Visibility ruleRender {#if doc.pageCount > 1}Never show "1 S."
-
-
-
- - -
-

04Distribution Bar · Close-Up

-

Only rendered in bilateral mode (both senderId and receiverId set). This component already exists in production as part of ConversationTimeline.svelte — this spec keeps its API and visual treatment identical but moves it out of the timeline header into a standalone component above the row list, so it can sit between the filter card and the year dividers.

- -
-
Distribution bar · bilateral Walter ↔ Herbert
-
-
- 87 von Walter de Gruyter → - ← 56 von Herbert Cram -
-
- - -
-
-
-
Distribution Barrole="img" + aria-label carries the data
- - - - - - - - - - - -
PartClassesRealNote
Wrapperflex flex-col gap-1 border-b border-line bg-muted px-[18px] py-28 px y paddingrole="img" · aria-label describes full distribution
Out labelinline-flex items-center gap-1 text-primary text-sm font-bold tabular-nums14 px / 700Format: "{count} von {sender} →"
In labelinline-flex items-center gap-1 text-accent text-sm font-bold tabular-nums14 px / 700Format: "← {count} von {receiver}"
Barflex h-[5px] overflow-hidden rounded-full bg-line5 px tallSegments use transition-[width] duration-300 ease-out
Out segmentbg-primary h-fullwidth from APIPercentage computed backend-side from counts
In segmentbg-accent h-fullcomplementaryNever use 100% - out; both come from the API separately
Mobile (320 px)Labels stack with flex-col gap-1; bar stays full-widthNo truncation of counts — numbers must always be legible
-
-
-
- - -
-

05Accessibility Contract · WCAG AA/AAA

-

Every colour pair on the rendered row has been measured. AAA where reasonably achievable; AA is the floor. The row is a link, not a button — keyboard navigation is native tab-through-list semantics.

- -
-
Light Mode — Contrast Verificationlayout.css tokens
- - - - - - - - - - - - -
PairValueRatioWCAG
Title (ink on surface)#1A1A1A on #ffffff19.6:1AAA ✓
Summary (ink-2 on surface)#444444 on #ffffff9.7:1AAA ✓ (body)
Meta (ink-3 on surface)#666666 on #ffffff5.7:1AA ✓
Direction out (primary on surface)#002850 on #ffffff14.5:1AAA ✓
Direction in (accent on surface)#2F9E95 on #ffffff4.6:1AA ✓ (normal)
Tag chip (primary on mint)#002850 on #a6dad88.1:1AAA ✓
Quote marks (accent on surface)#a6dad8 decorativen/aDecorative — summary text carries meaning
Focus ring (primary on surface)#002850 on #ffffff, 2px offset14.5:1AAA ✓
-
- -
-
Dark Mode — Contrast Verificationremap via data-theme="dark"
- - - - - - - - - - -
PairValueRatioWCAG
Title (ink on surface-dark)#f0efe9 on #011a3015.1:1AAA ✓
Summary (ink-2 on surface-dark)#c5cbd4 on #011a3011.2:1AAA ✓
Meta (ink-3 on surface-dark)#9ca3af on #011a307.8:1AAA ✓ (body)
Direction out (mint on canvas)#a1dcd8 on #010e1e9.6:1AAA ✓
Direction in (turquoise on canvas)#00c7b1 on #010e1e6.8:1AA ✓
Tag chip (turquoise on tint)#00c7b1 on rgba(0,199,177,.2)6.3:1AA ✓
-
- -
Non-negotiable accessibility rules. -
    -
  • Row is rendered as <a href="/documents/{id}"> — never <div onclick>. Keyboard Tab enters, Enter opens, Shift-Tab leaves.
  • -
  • Focus ring: focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 — always visible on keyboard focus, never on mouse click.
  • -
  • Thumbnail <img alt=""> — empty alt because the title next to it names the letter. A descriptive alt would be announced twice.
  • -
  • Direction glyph is color and shape (arrow direction). Never rely on color alone — the arrow "→" vs "←" carries meaning even in monochrome.
  • -
  • Distribution bar uses role="img" with a full-sentence aria-label. Screen readers hear the whole distribution in one announcement, not each half.
  • -
  • Minimum body text 14 px; minimum meta text 12 px. Never below 12 px for any visible text.
  • -
  • Touch target: 128 px row height ≫ 44 px WCAG minimum. Comfortable for senior users on phones.
  • -
  • prefers-reduced-motion: hover lift on thumbnail collapses to transition-duration: 0.01ms. Required (project CLAUDE.md + WCAG 2.3.3).
  • -
-
-
- - -
-

06Implementation Notes — Data, Thumbnails, Routing

- -
-
Data contract — fields read per row/api/documents/conversation
- - - - - - - - - - - - - - -
FieldFromUsed forFallback
idDocumentRow key, hrefrequired
titleDocumentRow titleoriginalFilename
summaryDocumentQuote line (omit when empty)element not rendered
documentDateDocumentYear group, right-column date, relative time"—" placeholder, year group "Ohne Datum"
locationDocumentMeta linehidden
sender / receiversDocumentDirection + counterpart namedirection omitted, name = m.conv_no_party()
tags[]DocumentMeta line (max 2 at 1440, 1 at 768, 0 at 320)no chips rendered
pageCountDocument (new, from thumbnail service)Badge when > 1no badge
thumbnailUrlDocument (new, from thumbnail service)<img src>skeleton until fetched
thumbnailAspectDocument (new, from thumbnail service)portrait / landscape classdefaults to portrait
-
- -
-
Thumbnail service — new endpointsdepends on open issue "thumbnail generation"
- - - - - - - - - - -
ConcernDecisionNote
StorageMinIO bucket thumbnailsMirrors document ID path; WEBP at 2× target resolution
URL/api/documents/{id}/thumbnailRedirects (302) to a presigned MinIO URL · Cache-Control: public, max-age=2592000 (30 d)
AspectComputed once on generation, persisted as Document.thumbnailAspect enum PORTRAIT \| LANDSCAPEThreshold w/h > 1.1 → LANDSCAPE
Page countPersisted as Document.pageCount on upload / reprocessNot computed client-side
Loading strategy<img loading="lazy" decoding="async"> with intersection observer for rows below the foldSkeleton state until onload fires
FallbackPaper-coloured placeholder (matches thumbnail gradient) with document iconNever break the row layout
-
- -
-
Component structurenew files
- - - - - - - - - -
FileResponsibilityReplaces
ThumbnailRow.svelteSingle row with thumbnail, title, summary, meta, right columnRow rendering inside ConversationTimeline.svelte
DistributionBar.svelteThe bilateral distribution barLifts existing markup out of ConversationTimeline.svelte
YearDivider.svelteYear number + Briefe countAlready exists; no change required
ConversationTimeline.svelteOrchestrator · renders distribution bar + year dividers + ThumbnailRowsSimplified — no longer does row markup directly
DocumentThumbnail.svelteReusable thumbnail element with lazy-load + aspect + page badgenew · also usable on /documents list pages
-
- -
Shipping order. -
    -
  • Phase 1 — land ThumbnailRow, DistributionBar (extracted), and new typography/spacing without real thumbnails. Thumbnail cell renders the skeleton permanently. Ship and observe.
  • -
  • Phase 2 — wire up thumbnail service (open issue "PDF thumbnail generation"). Replace skeleton with real thumbnails. Add thumbnailAspect + pageCount to the Document entity and the /api/documents/conversation response.
  • -
  • Phase 3 — add lazy-loading + intersection observer for rows outside viewport. Measure perf on 851-letter lists.
  • -
-
-
- -
- - diff --git a/docs/specs/conversations-narrow-column.html b/docs/specs/conversations-narrow-column.html deleted file mode 100644 index c2f1be18..00000000 --- a/docs/specs/conversations-narrow-column.html +++ /dev/null @@ -1,1252 +0,0 @@ - - - - - -Conversations — Narrow Column Redesign Spec · Familienarchiv - - - -
- - -
-
-

Conversations — Narrow Column Redesign

-

Developer specification for the WhatsApp/SMS-inspired chat layout. Constrains all bubble content to a centred 640 px column so opposing bubbles are close together — familiar to older users, easier to follow on wide screens.

-
- v1.0 · 2026-03-29 - 3 files changed - feature/153-notification-history branch -
-
-
- - -
-
-
0
-
- Architecture -
Files, routes, and constraints
-
Three files. One route. No new dependencies. All changes are presentational — the data layer, server load functions, and URL query-param contract are untouched.
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileWhat changesApprox. lines touched
frontend/src/routes/conversations/ConversationFilterBar.svelteAdd expanded bindable prop. Render either a collapsed strip or the full form depending on state. Add "Adjust" button. Add "Apply"/"Cancel" controls for overlay mode.+38 / −2
frontend/src/routes/conversations/ConversationTimeline.svelteRemove central vertical line. Wrap bubble list in max-w-[640px] mx-auto column. Change bubble max-width. Add status text labels alongside status dots.+14 / −6
frontend/src/routes/conversations/+page.svelteAdd filterExpanded reactive state. Auto-collapse when both persons are selected and documents load. Pass bind:expanded to FilterBar.+8 / −1
frontend/src/lib/paraglide/messages/*.json (de / en / es)Add 6 new i18n keys: conv_filter_adjust + 5 status label keys.+6 per file
- -
-
🏗 Architecture decisions
-
    -
  • Route stays at /conversations — query params (senderId, receiverId, from, to, dir) are the only data contract. No changes to +page.server.ts.
  • -
  • The filterExpanded flag lives in +page.svelte (not FilterBar) so the parent controls auto-collapse behaviour after navigation.
  • -
  • No JS animation libraries. Collapse uses a CSS transition: max-height or a Svelte #if block — developer's choice, but the spec uses #if for simplicity.
  • -
  • Breakpoints: mobile 375 px, tablet 768 px, desktop 1440 px. The 640 px column is achieved with Tailwind max-w-[640px] — no new CSS variables needed.
  • -
-
- -

Unchanged: +page.server.ts, the API client, all repository/service code, URL query-param names, Paraglide message IDs already in use, canWrite guard, and all existing test IDs (data-testid).

-
- - -
-
-
1
-
- Filter Bar -
Collapsible filter bar — 4 states
-
When no conversation is active the full form stays visible (nothing to collapse yet). Once both persons are selected and results load, the bar auto-collapses to a single-line strip. Tapping "Adjust" re-expands it as an inline overlay.
-
-
- - -
- -
-
1a Desktop 1440 No conversation selected
-
-
familienarchiv.local/conversations
-
- -
Dokumente
Gespräche
Personen
-
-
-
-
-
Gespräche
-
Briefwechsel zwischen Familienmitgliedern
-
- -
-
-
-
Person A
-
Name eingeben…
-
-
-
-
Person B
-
Name eingeben…
-
-
-
-
-
Von Datum
-
TT.MM.JJJJ
-
-
-
Bis Datum
-
TT.MM.JJJJ
-
-
-
 
-
Sortierung: Neueste zuerst ▾
-
-
-
↑ No collapse button — conversation not yet active
-
- -
-
👥
-
Wähle zwei Personen aus
-
Wähle Person A und Person B aus, um ihre Korrespondenz anzuzeigen
-
-
-
-
Full filter form always expanded when senderId or receiverId is absent. No collapse/strip needed — there is no conversation to preserve context from.
-
- - -
-
1b Desktop 1440
-
-
familienarchiv.local/conversations?senderId=…&receiverId=…
-
- -
Dokumente
Gespräche
Personen
-
-
-
-
-
Gespräche
-
Briefwechsel zwischen Familienmitgliedern
-
- -
-
-
-
Anna Müller ⇄ Heinrich Raddatz
-
47 Dokumente · 1928–1965
-
-
-
Anpassen
-
- -
-
47 Dokumente · 1928–1965
- -
- -
-
-
1928
-
-
-
AM
-
-
Brief an Heinrich, April 1928
-
12.04.1928 · München
-
Hochgeladen
-
-
-
-
-
-
HR
-
-
Antwort vom 3. Mai
-
03.05.1928 · Berlin
-
Transkribiert
-
-
-
-
-
-
AM
-
-
Postkarte, Sommer 1928
-
15.07.1928
-
Geprüft
-
-
-
-
-
-
-
-
Auto-collapsed to single strip after results load. "Anpassen" button re-expands. Year + count visible in strip for quick orientation without opening filters.
-
-
- - -
- -
-
1c Desktop 1440 Overlay expanded after "Adjust"
-
-
familienarchiv.local/conversations?senderId=…
-
- -
Dokumente
Gespräche
-
-
-
-
Gespräche
- -
-
Anna Müller ⇄ Heinrich Raddatz
-
Anpassen
-
- -
-
Filter anpassen
-
-
-
Person A
-
Anna Müller
-
-
-
-
Person B
-
Heinrich Raddatz
-
-
-
-
-
Von Datum
-
-
-
-
Bis Datum
-
-
-
-
 
-
Neueste zuerst ▾
-
-
-
Anwenden
-
Abbrechen
-
- -
-
-
Brief an Heinrich…
-
-
-
-
-
Overlay drops inline below the strip (not a floating modal). Strip remains visible at reduced opacity. "Abbrechen" dismisses back to collapsed. "Anwenden" fires applyFilters() and collapses.
-
- - -
-
1d Mobile 375 Mobile — both states
-
- -
-
Collapsed
-
-
-
-
-
Gespräche
-
-
-
A. Müller → H. Raddatz
-
47 Dok. · 1928–1965
-
-
Anpassen
-
-
-
-
1928
-
-
-
-
Brief, April 1928
-
12.04.1928
-
-
-
-
-
-
-
Antwort, Mai 1928
-
03.05.1928
-
-
-
-
-
-
-
-
- -
-
Expanded
-
-
-
-
-
Gespräche
-
-
Person A
-
Anna Müller
-
⇅ Tauschen
-
Person B
-
Heinrich Raddatz
-
-
-
Von
-
-
-
-
Bis
-
-
-
-
Neueste zuerst ▾
-
Anwenden
-
Abbrechen
-
-
-
-
-
-
Mobile: full-width tap target on collapsed strip. Expanded mode stacks all fields vertically. Swap button sits between Person A and B. "Abbrechen" collapses without navigating.
-
-
- -
-
📐 Filter bar prop contract (updated)
-
    -
  • Add expanded = $bindable(true) to FilterBar's prop definition. Default true so existing tests that don't pass the prop still see the full form.
  • -
  • In +page.svelte: let filterExpanded = $state(!data.filters.senderId || !data.filters.receiverId). After successful load with results, set filterExpanded = false inside the $effect that syncs filter values.
  • -
  • "Anwenden" inside the expanded overlay calls onapplyFilters() then sets expanded = false (local) — the parent's bound state updates via Svelte 5 bindable.
  • -
  • "Abbrechen" sets expanded = false without calling onapplyFilters().
  • -
-
-
- - -
-
-
2
-
- Chat Column -
Narrow column layout — 3 breakpoints
-
The structural change. All bubble content is centred in a max-w-[640px] column inside the existing chat container. The outer container (border, shadow, surface bg) stays at full page width. No central dividing line.
-
-
- - -
- -
-
Before — full-width split
-
- -
- -
-
Brief, April 1928
-
12.04.1928
-
- -
-
Antwort, Mai 1928
-
03.05.1928
-
- -
-
Postkarte, Sommer
-
15.07.1928
-
- -
← wide gap →
-
-
- -
- -
-
After — narrow 640 px column
-
- -
-
-
-
Brief, April 1928
-
12.04.1928
-
-
-
-
-
Antwort, Mai 1928
-
03.05.1928
-
-
-
-
-
Postkarte, Sommer
-
15.07.1928
-
-
-
- -
max-w-[640px] · mx-auto
-
-
-
- -
- -
-
2a Desktop 1440
-
-
familienarchiv.local/conversations?senderId=a1&receiverId=b2
-
Dokumente
Gespräche
Personen
-
-
Gespräche
-
Anna Müller ⇄ Heinrich Raddatz
47 Dokumente · 1928–1965
Anpassen
-
47 Dokumente · 1928–1965
- -
- -
-
1928
-
-
-
AM
-
-
Brief an Heinrich, April 1928
-
12.04.1928 · München
-
Hochgeladen
-
-
-
-
-
-
HR
-
-
Antwort vom 3. Mai 1928
-
03.05.1928 · Berlin
-
Transkribiert
-
-
-
-
-
-
AM
-
-
Postkarte, Sommer 1928
-
15.07.1928
-
Geprüft
-
-
-
-
1929
-
-
-
HR
-
-
Neujahrskarte 1929
-
01.01.1929
-
Archiviert
-
-
-
-
- -
⟵ outer container: full page width (max-w-5xl) · chat column: max-w-[640px] centred ⟶
-
-
-
-
Desktop 1440 px. Chat outer container spans to page max-w-5xl (unchanged). Bubble column is max-w-[640px] mx-auto inside. The grey padding on each side is empty space — visually frames the conversation. No central line.
-
- - -
-
2b Tablet 768
-
-
-
Gespräche
-
-
-
A. Müller ⇄ H. Raddatz
47 Dok. · 1928–1965
-
Anpassen
-
-
47 Dokumente · 1928–1965
-
-
-
1928
-
-
-
AM
-
-
Brief an Heinrich, April
-
12.04.1928
-
Hochgeladen
-
-
-
-
-
-
HR
-
-
Antwort, Mai 1928
-
03.05.1928
-
Transkribiert
-
-
-
-
-
-
-
-
Tablet 768 px. Column narrows to max-w-[560px]. Avatars visible (sm:flex). Filter strip collapsed.
-
- - -
-
2c Mobile 375
-
-
-
-
-
Gespräche
-
-
A. Müller → H. Raddatz
-
Anpassen
-
-
- -
-
1928
-
-
- -
-
Brief an Heinrich, April 1928
-
12.04.1928
-
Hochgeladen
-
-
-
-
-
-
-
Antwort, Mai 1928
-
03.05.1928
-
Transkribiert
-
-
-
-
-
-
-
Postkarte, Sommer
-
15.07.1928
-
Geprüft
-
-
-
-
-
-
-
-
Mobile 375 px. No avatars (hidden sm:flex). Bubbles max-w-[85%]. Column fills full width naturally — no mx-auto padding needed.
-
-
-
- - -
-
-
3
-
- Bubble Card -
Bubble card anatomy — sender & receiver
-
The cards are links (<a href="/documents/{id}">). The key WCAG fix: status indicator gains a text label next to the coloured dot so colour is no longer the sole information channel.
-
-
- -
- -
-
3a — Sender bubble (right-aligned, navy)
-
- -
-
-
AM
-
-
Brief an Heinrich Raddatz, April 1928
-
12.04.1928 · München
-
-
-
Hochgeladen
-
-
-
-
- -
- - - - - - - - - -
Containermax-w-[80%] (was md:max-w-[70%]). No breakpoint prefix needed — column is already narrow.
Corner cutrounded rounded-br-none — WhatsApp-style tail at bottom-right
Backgroundbg-primary (#002850 navy)
Titlefont-serif text-sm font-medium text-primary-fg
Meta rowfont-sans text-[10px] tracking-wider uppercase text-primary-fg/70
Status — NEWflex items-center gap-1 → dot + text label (see note below)
Hoverhover:-translate-y-0.5 hover:shadow-md — retained unchanged
Avatarhidden sm:flex — 32×32, navy fill, white initials
-
-
-
- - -
-
3b — Receiver bubble (left-aligned, grey)
-
-
-
-
HR
-
-
Antwort vom 3. Mai 1928
-
03.05.1928 · Berlin
-
-
-
Transkribiert
-
-
-
-
-
- - - - - - - -
Corner cutrounded rounded-bl-none — tail at bottom-left
Backgroundbg-muted/50 (light grey, ~#E8E4DF)
Titlefont-serif text-sm font-medium text-ink
Meta rowfont-sans text-[10px] tracking-wider uppercase text-ink-2
Status — NEWSame structure, label colour text-ink-2 (dark enough for contrast)
Avatarhidden sm:flex — 32×32, white bg, ink initials, border
-
-
-
-
- - -
-
Status dot → label mapping (ConversationTimeline.svelte)
- - - - - - - - - -
DocumentStatusDot colour classLabel key (i18n)deSender text colourReceiver text colour
PLACEHOLDERbg-yellow-400conv_status_placeholderPlatzhaltertext-primary-fg/60text-ink-2
UPLOADEDbg-accent (green)conv_status_uploadedHochgeladentext-primary-fg/65text-ink-2
TRANSCRIBEDbg-blue-400conv_status_transcribedTranskribierttext-primary-fg/65text-ink-2
REVIEWEDbg-purple-400conv_status_reviewedGeprüfttext-primary-fg/65text-ink-2
ARCHIVEDbg-gray-400conv_status_archivedArchivierttext-primary-fg/50text-ink-3
-

Implement as a const statusLabels: Record<string, string> in ConversationTimeline.svelte using m.conv_status_* functions. Fall back to doc.status for unknown values.

-
-
- - -
-
-
4
-
- Year Dividers -
Year dividers inside the narrow column
-
No HTML change needed. The existing flex items-center with flex-grow border-t lines auto-sizes to whatever container it sits in. Moving the bubble list inside the 640 px column automatically constrains dividers too.
-
-
- -
-
-
4a — Year divider in narrow column
-
-
- -
-
-
Brief, Dezember 1928
18.12.1928
-
-
- -
-
-
1929
-
-
- -
-
-
Neujahrskarte 1929
01.01.1929
-
-
-
-
- Unchanged Svelte code:
- <div class="relative flex items-center py-2 text-center">
-   <div class="flex-grow border-t border-line"></div>
-   <span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase">{year}</span>
-   <div class="flex-grow border-t border-line"></div>
- </div>

- The only structural change is that this element now lives inside the max-w-[640px] mx-auto wrapper div instead of the previous full-width flex column. -
-
-
-
-
- - -
-
-
5
-
- Summary Bar -
Summary bar — above the narrow column
-
The summary bar (count + year range + new doc link) sits outside the narrow column at full page width. It remains at max-w-5xl because it is UI chrome, not conversation content.
-
-
- -
-
5a — Summary bar placement
-
- -
-
max-w-5xl (page column)
- -
- 47 Dokumente · 1928–1965 - + Neues Dokument -
- -
-
chat container — full width, bg-surface border shadow
- -
-
max-w-[640px] · mx-auto
-
bubble list lives here
-
-
-
-

The mb-4 flex items-center justify-between summary div in ConversationTimeline.svelte is unchanged — it is already outside the chat container div. No code change needed for the summary bar itself.

-
-
-
- - -
-
-
6
-
- Empty States -
Two empty state variants
-
The "no conversation selected" state keeps the full expanded filter bar (no strip). The "conversation selected but no results" state uses the collapsed strip and a compact empty message.
-
-
- -
- -
-
6a — No conversation selected (senderId or receiverId missing)
-
-
familienarchiv.local/conversations
-
Gespräche
-
-
Gespräche
Briefwechsel zwischen Familienmitgliedern
-
-
-
Person A
Name eingeben…
-
-
Person B
Heinrich Raddatz
-
-
-
Von Datum
TT.MM.JJJJ
-
Bis Datum
TT.MM.JJJJ
-
 
Neueste zuerst ▾
-
-
-
-
👥
-
Wähle zwei Personen aus
-
Wähle Person A und Person B aus, um ihren Briefwechsel anzuzeigen
-
-
-
-
Full filter bar expanded (filterExpanded = true). "conv_empty_heading" / "conv_empty_text" message keys unchanged. Empty state uses dashed border to signal "waiting for input".
-
- - -
-
6b — Conversation selected, no results found
-
-
familienarchiv.local/conversations?senderId=a1&receiverId=b2&from=1960…
-
Gespräche
-
-
Gespräche
-
-
Anna Müller ⇄ Heinrich Raddatz
Von 1960 · Neueste zuerst
-
Anpassen
-
-
-
🔍
-
Keine Dokumente gefunden
-
Für diesen Zeitraum wurden keine Dokumente gefunden. Passe die Filter an.
-
Anpassen
-
-
-
-
Collapsed strip still shows who the conversation is between. Compact empty state with an inline "Anpassen" CTA that triggers filterExpanded = true. Uses "conv_no_results_heading" / "conv_no_results_text" keys unchanged.
-
-
-
- - -
-
-
7
-
- Implementation -
Implementation notes — developer checklist
-
Numbered rules. Each rule maps to a specific line-level change in one of the three files.
-
-
- -
-
-
-
1
-
-
Narrow column container (ConversationTimeline.svelte)
-
Wrap the flex flex-col gap-4 div (currently inside p-6 md:p-8) with <div class="max-w-[640px] mx-auto">. The outer relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm container stays unchanged at full page width.
-
-
-
-
2
-
-
Remove central vertical line (ConversationTimeline.svelte)
-
Delete the entire <div class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"></div> element. It served as a decorative lane divider on wide screens; inside a narrow column it would bisect the bubbles incorrectly.
-
-
-
-
3
-
-
Bubble max-width (ConversationTimeline.svelte)
-
Change max-w-[90%] md:max-w-[70%] to max-w-[80%]. No breakpoint prefix is needed — the column itself is narrow so 80 % of 640 px (~512 px) is the effective max on all screens.
-
-
-
-
4
-
-
Status text label (ConversationTimeline.svelte — WCAG fix)
-
Replace the lone <span class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full … " title={doc.status}></span> with:
- <span class="flex items-center gap-1"><span class="h-1.5 w-1.5 flex-shrink-0 rounded-full {colorClass}"></span><span class="text-[9px] font-sans uppercase tracking-wider {labelColorClass}">{statusLabel}</span></span>
- Define const statusLabels: Record<string, string> mapping status codes to m.conv_status_*() calls. Derive statusLabel as statusLabels[doc.status] ?? doc.status.
-
-
-
-
5
-
-
filterExpanded state (+page.svelte)
-
Add let filterExpanded = $state(!data.filters.senderId || !data.filters.receiverId) after the existing state declarations. Inside the existing $effect that syncs filter values, append: if (senderId && receiverId) filterExpanded = false;. This auto-collapses after a successful navigation that loads results.
-
-
-
-
6
-
-
Pass expanded to FilterBar (+page.svelte)
-
Add bind:expanded={filterExpanded} to the <ConversationFilterBar …> element. This is the only change to the JSX-like template in +page.svelte beyond step 5.
-
-
-
-
7
-
-
Collapsed strip rendering (ConversationFilterBar.svelte)
-
Add expanded = $bindable(true) to the prop definition. Wrap the existing form content in {#if expanded}…{/if}. Add a new {:else} branch that renders the single-line strip: <div class="flex items-center justify-between px-4 py-2 border-b border-line bg-surface mb-10"> containing person names + "Anpassen" button. The button's onclick sets expanded = true.
-
-
-
-
8
-
-
Apply / Cancel in expanded overlay mode (ConversationFilterBar.svelte)
-
When expanded is true and a conversation was previously active (i.e. senderId && receiverId are non-empty at mount time), render "Anwenden" and "Abbrechen" buttons at the bottom of the form. "Anwenden" calls onapplyFilters() then sets expanded = false. "Abbrechen" only sets expanded = false. When no conversation is active, these extra buttons are not shown — the person typeahead's onchange already fires onapplyFilters() automatically.
-
-
-
-
9
-
-
Avatar visibility unchanged
-
Keep hidden sm:block on the avatar wrapper. On mobile (375 px) avatars are hidden; on tablet+ they show at 32×32. No change needed — this class already exists in ConversationTimeline.svelte.
-
-
-
-
10
-
-
Year divider — no change
-
The existing data-testid="year-divider" element uses flex-grow border-t which auto-fills its container. Moving it inside the max-w-[640px] wrapper is the only structural change, and that is covered by rule 1. No attribute or class change needed on the divider element itself.
-
-
-
-
11
-
-
i18n keys to add
-
Add 6 keys to messages/de.json, en.json, and es.json. See i18n table below.
-
-
-
-
12
-
-
Keyboard / accessibility
-
"Anpassen" is a <button> — natively focusable, responds to Enter/Space. No ARIA additions needed beyond what a standard button provides. The status text label (rule 4) resolves the only existing WCAG colour-only information issue on this page.
-
-
-
-
13
-
-
canWrite guard unchanged
-
The {#if canWrite} block around the "+ Neues Dokument" link in ConversationTimeline.svelte is outside the narrow column wrapper (it sits in the summary bar). No change needed.
-
-
-
-
14
-
-
data-testid attributes preserved
-
Existing test IDs must not be removed: conv-swap-btn, conv-summary, conv-new-doc-link, year-divider. The new "Anpassen" button should receive data-testid="conv-filter-adjust-btn".
-
-
-
-
- - -
-
i18n keys to add (messages/de.json · en.json · es.json)
- - - - - - - - - - - - -
Keydeenes
conv_filter_adjustAnpassenAdjustAjustar
conv_filter_applyAnwendenApplyAplicar
conv_filter_cancelAbbrechenCancelCancelar
conv_status_placeholderPlatzhalterPlaceholderMarcador
conv_status_uploadedHochgeladenUploadedSubido
conv_status_transcribedTranskribiertTranscribedTranscrito
conv_status_reviewedGeprüftReviewedRevisado
conv_status_archivedArchiviertArchivedArchivado
-
-
- - -
-
-
Δ
-
- Comparison -
Before / after — full diff summary
-
Side-by-side summary of every meaningful behavioural and visual change.
-
-
- -
-
-
Before
-
-
Full filter form always visible, never collapses
-
Bubbles race to opposite edges of 100 % screen width
-
Central vertical grey line bisects wide chat container
-
Bubble max-width: md:max-w-[70%] — up to 70 % of full page at desktop
-
Status: colour-only dot, title tooltip (WCAG failure)
-
Status dot classes: only bg-accent (uploaded) vs bg-yellow-400 (all else)
-
No "Anpassen" / "Anwenden" / "Abbrechen" controls
-
Mobile: avatars hidden by hidden sm:block — correct but max-width too wide
-
-
-
-
After
-
-
Filter auto-collapses to single strip when conversation loads; re-expands on demand
-
All bubbles centred in max-w-[640px] column — tight WhatsApp-style gap
-
Central line removed — irrelevant inside narrow column
-
Bubble max-width: max-w-[80%] — 80 % of 640 px = ~512 px, same visual weight across breakpoints
-
Status: dot + text label side-by-side (WCAG 1.4.1 satisfied)
-
Status dot has 5 distinct classes mapping to full DocumentStatus lifecycle
-
FilterBar gains "Anpassen" (strip), "Anwenden" + "Abbrechen" (overlay)
-
Mobile unchanged in avatar logic; bubble group max-width explicitly set to max-w-[85%]
-
-
-
-
- -
- - diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 92ae7396..a6fa8df7 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -29,7 +29,6 @@ src/ │ ├── +page.svelte # Home / document search dashboard │ ├── documents/ # Document CRUD, detail, edit, upload │ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage) -│ ├── briefwechsel/ # Bilateral conversation timeline │ ├── aktivitaeten/ # Unified activity feed (Chronik) │ ├── admin/ # User, group, tag, OCR, system management │ ├── api/ # Internal API proxies (server-side only) @@ -53,7 +52,6 @@ src/ │ ├── geschichte/ # Geschichte (story) domain: editor + card │ ├── notification/ # Notification bell + dropdown + store │ ├── activity/ # Activity feed (Chronik) components -│ ├── conversation/ # Bilateral conversation (Briefwechsel) components │ ├── ocr/ # OCR progress, training cards, trigger │ ├── user/ # User profile/password/groups section components │ ├── shared/ # Cross-domain utilities and primitives diff --git a/frontend/e2e/CLAUDE.md b/frontend/e2e/CLAUDE.md index b3e4c4a4..cc33c7a5 100644 --- a/frontend/e2e/CLAUDE.md +++ b/frontend/e2e/CLAUDE.md @@ -25,7 +25,7 @@ frontend/e2e/ ├── focus-rings.spec.ts # Accessibility focus ring tests ├── header.spec.ts # Navigation header ├── history.spec.ts # Chronik / activity feed -├── korrespondenz.spec.ts # Correspondence timeline +├── briefwechsel-removed.spec.ts # Guards that the removed /briefwechsel route 404s ├── lang.spec.ts # Language switching ├── password-reset.spec.ts # Password reset flow ├── permissions.spec.ts # Role-based access control diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index a7b2d527..5590ab1a 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -26,7 +26,7 @@ test.describe('Authentication', () => { }); test('protected routes redirect to /login without session', async ({ page }) => { - for (const url of ['/documents/new', '/persons', '/briefwechsel']) { + for (const url of ['/documents/new', '/persons']) { await page.goto(url); await expect(page).toHaveURL(/\/login/); } diff --git a/frontend/e2e/briefwechsel-a11y.spec.ts b/frontend/e2e/briefwechsel-a11y.spec.ts deleted file mode 100644 index 74d659b9..00000000 --- a/frontend/e2e/briefwechsel-a11y.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -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-removed.spec.ts b/frontend/e2e/briefwechsel-removed.spec.ts new file mode 100644 index 00000000..f9dc7a7b --- /dev/null +++ b/frontend/e2e/briefwechsel-removed.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; + +// The standalone Briefwechsel view was removed (its one inbound link now +// deep-links into document search). The old URL is allowed to 404 — no +// redirect shim. This guard runs in the authenticated project so the route +// genuinely 404s on the styled app error page instead of bouncing to /login. +test.describe('Briefwechsel view removed', () => { + test('/briefwechsel returns 404 on the styled app error page', async ({ page }) => { + const response = await page.goto('/briefwechsel'); + + expect(response?.status()).toBe(404); + // +error.svelte renders the status code prominently. + await expect(page.getByText('404')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/briefwechsel-rows.visual.spec.ts b/frontend/e2e/briefwechsel-rows.visual.spec.ts deleted file mode 100644 index 3b0991bb..00000000 --- a/frontend/e2e/briefwechsel-rows.visual.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index ee32ef96..00000000 --- a/frontend/e2e/fixtures/bilateral-correspondence.ts +++ /dev/null @@ -1,62 +0,0 @@ -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/e2e/korrespondenz.spec.ts b/frontend/e2e/korrespondenz.spec.ts deleted file mode 100644 index 6fcf0680..00000000 --- a/frontend/e2e/korrespondenz.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -function buildAxe(page: Parameters[0]['page']) { - return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']); -} - -test.describe('Korrespondenz – empty state', () => { - test('shows the search heading when no person is selected', async ({ page }) => { - await page.goto('/korrespondenz'); - await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible(); - const a11y = await buildAxe(page).analyze(); - expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' }); - }); - - test('nav link goes to /korrespondenz', async ({ page }) => { - await page.goto('/'); - // Click the nav link (desktop text or mobile icon) - const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first(); - await navLink.click(); - await expect(page).toHaveURL(/\/korrespondenz/); - }); -}); - -test.describe('Korrespondenz – single-person mode', () => { - test('shows hint bar and documents when navigated with senderId', async ({ page }) => { - // Get a real person ID from the persons list - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - - // Extract the person ID from the URL - const personId = page.url().split('/persons/')[1].split('?')[0]; - - // Navigate to korrespondenz in single-person mode - await page.goto(`/korrespondenz?senderId=${personId}`); - - // Hint bar should be visible - await expect(page.getByText(/Alle Briefe von/i)).toBeVisible(); - - // Filter controls should be active (not dimmed) - const filterStrip = page.locator('[aria-disabled="false"]').first(); - await expect(filterStrip).toBeAttached(); - - const a11y = await buildAxe(page).analyze(); - expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' }); - }); - - test('sort toggle changes URL direction param', async ({ page }) => { - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - const personId = page.url().split('/persons/')[1].split('?')[0]; - - await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`); - await page.getByTestId('conv-sort-btn').click(); - - await expect(page).toHaveURL(/dir=ASC/); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' }); - }); -}); - -test.describe('Korrespondenz – bilateral mode', () => { - test('shows asymmetry bar when both persons have shared documents', async ({ page }) => { - // Navigate to a person then follow a co-correspondent suggestion if available - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - const senderId = page.url().split('/persons/')[1].split('?')[0]; - - // Try to find a co-correspondent link from the person detail page - const corrLink = page - .locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]') - .first(); - if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) { - await corrLink.click(); - await page.waitForURL(/\/korrespondenz\?.*receiverId=/); - - // Hint bar should NOT be shown in bilateral mode - await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible(); - - const a11y = await buildAxe(page).analyze(); - expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' }); - } else { - // E2E seed must include bilateral correspondents — a missing link is a test failure. - throw new Error( - `No bilateral correspondent links found for person ${senderId}. Ensure the E2E seed contains at least one bilateral correspondence pair.` - ); - } - }); - - test('swap button swaps sender and receiver in URL', async ({ page }) => { - await page.goto('/persons'); - const firstPersonLink = page.locator('a[href^="/persons/"]').first(); - await firstPersonLink.click(); - await page.waitForURL(/\/persons\/.+/); - const senderId = page.url().split('/persons/')[1].split('?')[0]; - - const corrLink = page - .locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]') - .first(); - if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) { - const href = await corrLink.getAttribute('href'); - await corrLink.click(); - await page.waitForURL(/\/korrespondenz\?.*receiverId=/); - - // Extract original receiverId from the href - const url = new URL(href!, 'http://x'); - const originalReceiverId = url.searchParams.get('receiverId')!; - - // Click swap - await page.getByTestId('conv-swap-btn').click(); - - // After swap the former receiver is now senderId - await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`)); - await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' }); - } else { - test.skip(true, `No bilateral correspondent links found for person ${senderId}`); - } - }); -}); diff --git a/frontend/e2e/stammbaum.spec.ts b/frontend/e2e/stammbaum.spec.ts index ed414efb..6b5625d9 100644 --- a/frontend/e2e/stammbaum.spec.ts +++ b/frontend/e2e/stammbaum.spec.ts @@ -4,14 +4,6 @@ import { test, expect } from '@playwright/test'; test.describe('Stammbaum — issue #358', () => { test.skip(); - test('nav swap: /briefwechsel still renders without 404', async ({ page }) => { - // Plan journey 4: the /briefwechsel route must stay intact even though the - // AppNav now points at /stammbaum. - const response = await page.goto('/briefwechsel'); - expect(response?.status()).toBeLessThan(400); - await expect(page).toHaveURL(/\/briefwechsel/); - }); - test('/stammbaum renders the page heading', async ({ page }) => { await page.goto('/stammbaum'); await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible(); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 21fc48aa..91c431f9 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -26,7 +26,6 @@ "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", "nav_persons": "Personen", - "nav_conversations": "Briefwechsel", "nav_admin": "Admin", "nav_logout": "Abmelden", "layout_menu_open": "Menü öffnen", @@ -189,29 +188,12 @@ "person_role_sender": "Gesendet", "person_role_receiver": "Empfangen", "person_co_correspondents_heading": "Häufige Korrespondenten", - "person_correspondents_hint": "klicken für Konversation", + "person_correspondents_search_title": "Briefe von {A} an {B} durchsuchen", + "person_correspondents_search_hint": "klicken, um Briefe zu durchsuchen", + "person_correspondents_badge_title": "Gemeinsame Briefe in beide Richtungen", "person_show_more": "+ {count} weitere anzeigen", - "conv_label_person_a": "Person A (Absender)", - "conv_label_person_b": "Korrespondent", - "conv_label_from": "Zeitraum von", - "conv_label_to": "Zeitraum bis", - "conv_sort_label": "Sortierung:", "conv_sort_newest": "Neueste zuerst", "conv_sort_oldest": "Älteste zuerst", - "conv_empty_heading": "Wessen Briefe möchten Sie lesen?", - "conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche", - "conv_no_results_heading": "Keine Dokumente gefunden.", - "conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.", - "conv_swap_btn": "Personen tauschen", - "conv_new_doc_link": "Neues Dokument in diesem Briefwechsel", - "conv_strip_sort_newest": "Neueste", - "conv_strip_sort_oldest": "Älteste", - "conv_suggestions_heading": "Häufigste Korrespondenten", - "conv_suggestions_all_label": "Alle Korrespondenten von {name}", - "conv_letters_count": "{count} Briefe", - "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", @@ -292,7 +274,6 @@ "topbar_overflow_heading": "Weitere Empfänger", "topbar_overflow_show": "{count} weitere Empfänger anzeigen", "doc_tag_filter_title": "Nach {name} filtern", - "doc_conversation_title": "Konversation anzeigen", "doc_preview_iframe_title": "Dokumentvorschau", "doc_image_alt": "Original-Scan", "doc_no_date": "Kein Datum", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 55b12b51..ca1144b5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -26,7 +26,6 @@ "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", "nav_persons": "Persons", - "nav_conversations": "Letters", "nav_admin": "Admin", "nav_logout": "Sign out", "layout_menu_open": "Open menu", @@ -189,29 +188,12 @@ "person_role_sender": "Sent", "person_role_receiver": "Received", "person_co_correspondents_heading": "Frequent correspondents", - "person_correspondents_hint": "click to view conversation", + "person_correspondents_search_title": "Search letters from {A} to {B}", + "person_correspondents_search_hint": "click to search letters", + "person_correspondents_badge_title": "Shared letters in both directions", "person_show_more": "+ {count} more", - "conv_label_person_a": "Person A (Sender)", - "conv_label_person_b": "Correspondent", - "conv_label_from": "Period from", - "conv_label_to": "Period to", - "conv_sort_label": "Sort:", "conv_sort_newest": "Newest first", "conv_sort_oldest": "Oldest first", - "conv_empty_heading": "Whose letters would you like to read?", - "conv_hero_crosslink": "Looking for a specific document? → Go to document search", - "conv_no_results_heading": "No documents found.", - "conv_no_results_text": "Try adjusting the time period.", - "conv_swap_btn": "Swap persons", - "conv_new_doc_link": "New document in this exchange", - "conv_strip_sort_newest": "Newest", - "conv_strip_sort_oldest": "Oldest", - "conv_suggestions_heading": "Top correspondents", - "conv_suggestions_all_label": "All correspondents of {name}", - "conv_letters_count": "{count} letters", - "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", @@ -292,7 +274,6 @@ "topbar_overflow_heading": "More receivers", "topbar_overflow_show": "Show {count} more receivers", "doc_tag_filter_title": "Filter by {name}", - "doc_conversation_title": "Show conversation", "doc_preview_iframe_title": "Document Preview", "doc_image_alt": "Original scan", "doc_no_date": "No date", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index cdebf5db..a2e21a7b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -26,7 +26,6 @@ "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", "nav_persons": "Personas", - "nav_conversations": "Cartas", "nav_admin": "Admin", "nav_logout": "Cerrar sesión", "layout_menu_open": "Abrir menú", @@ -189,29 +188,12 @@ "person_role_sender": "Enviado", "person_role_receiver": "Recibido", "person_co_correspondents_heading": "Corresponsales frecuentes", - "person_correspondents_hint": "clic para ver conversación", + "person_correspondents_search_title": "Buscar cartas de {A} a {B}", + "person_correspondents_search_hint": "haz clic para buscar cartas", + "person_correspondents_badge_title": "Cartas compartidas en ambas direcciones", "person_show_more": "+ {count} más", - "conv_label_person_a": "Persona A (Remitente)", - "conv_label_person_b": "Corresponsal", - "conv_label_from": "Período desde", - "conv_label_to": "Período hasta", - "conv_sort_label": "Ordenar:", "conv_sort_newest": "Más reciente primero", "conv_sort_oldest": "Más antiguo primero", - "conv_empty_heading": "¿De quién desea leer las cartas?", - "conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda", - "conv_no_results_heading": "No se encontraron documentos.", - "conv_no_results_text": "Intente ajustar el período de tiempo.", - "conv_swap_btn": "Intercambiar personas", - "conv_new_doc_link": "Nuevo documento en este intercambio", - "conv_strip_sort_newest": "Más reciente", - "conv_strip_sort_oldest": "Más antiguo", - "conv_suggestions_heading": "Corresponsales frecuentes", - "conv_suggestions_all_label": "Todos los corresponsales de {name}", - "conv_letters_count": "{count} cartas", - "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", @@ -292,7 +274,6 @@ "topbar_overflow_heading": "Más destinatarios", "topbar_overflow_show": "Mostrar {count} destinatarios más", "doc_tag_filter_title": "Filtrar por {name}", - "doc_conversation_title": "Ver conversación", "doc_preview_iframe_title": "Vista previa del documento", "doc_image_alt": "Escaneado original", "doc_no_date": "Sin fecha", diff --git a/frontend/src/lib/conversation/ConversationThumbnail.svelte b/frontend/src/lib/document/ConversationThumbnail.svelte similarity index 100% rename from frontend/src/lib/conversation/ConversationThumbnail.svelte rename to frontend/src/lib/document/ConversationThumbnail.svelte diff --git a/frontend/src/lib/conversation/ConversationThumbnail.svelte.spec.ts b/frontend/src/lib/document/ConversationThumbnail.svelte.spec.ts similarity index 100% rename from frontend/src/lib/conversation/ConversationThumbnail.svelte.spec.ts rename to frontend/src/lib/document/ConversationThumbnail.svelte.spec.ts diff --git a/frontend/src/lib/document/ThumbnailRow.svelte b/frontend/src/lib/document/ThumbnailRow.svelte index a5255112..440f58bd 100644 --- a/frontend/src/lib/document/ThumbnailRow.svelte +++ b/frontend/src/lib/document/ThumbnailRow.svelte @@ -1,5 +1,5 @@ - -{#if showHero} - -
- -
-{:else} - -
-
- (showAdvanced = !showAdvanced)} - /> - - {#if showAdvanced} - - {/if} - - {#if isSinglePerson} - - {/if} -
- -
- {#if data.documents.length === 0} -
-

{m.conv_no_results_heading()}

-

{m.conv_no_results_text()}

-
- {:else} - - {/if} -
-
-{/if} diff --git a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte b/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte deleted file mode 100644 index 1ac6d236..00000000 --- a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte +++ /dev/null @@ -1,142 +0,0 @@ - - -
-
- -
- onapplyFilters()} - /> -
- - -
- -
- - -
- onapplyFilters()} - /> -
-
- -
- -
- - onapplyFilters()} - class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
- - -
- - onapplyFilters()} - class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
- - -
- -
-
-
diff --git a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte.test.ts b/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte.test.ts deleted file mode 100644 index 8b4578a9..00000000 --- a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import ConversationFilterBar from './ConversationFilterBar.svelte'; - -afterEach(cleanup); - -const baseProps = (overrides: Record = {}) => ({ - senderId: '', - receiverId: '', - fromDate: '', - toDate: '', - sortDir: 'DESC', - initialSenderName: '', - initialReceiverName: '', - onapplyFilters: () => {}, - ontoggleSort: () => {}, - onswapPersons: () => {}, - ...overrides -}); - -describe('ConversationFilterBar', () => { - it('renders the two PersonTypeahead inputs and the date inputs', async () => { - render(ConversationFilterBar, { props: baseProps() }); - - const dateInputs = document.querySelectorAll('input[type="date"]'); - expect(dateInputs.length).toBe(2); - }); - - it('marks the swap button invisible when only one person is set', async () => { - render(ConversationFilterBar, { props: baseProps({ senderId: 'p1' }) }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.className).toContain('invisible'); - }); - - it('marks the swap button visible when both persons are set', async () => { - render(ConversationFilterBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2' }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.className).not.toContain('invisible'); - }); - - it('renders "Neueste zuerst" when sortDir is DESC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'DESC' }) }); - - await expect.element(page.getByText('Neueste zuerst')).toBeVisible(); - }); - - it('renders "Älteste zuerst" when sortDir is ASC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'ASC' }) }); - - await expect.element(page.getByText('Älteste zuerst')).toBeVisible(); - }); - - it('rotates the chevron 180° when sortDir is ASC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'ASC' }) }); - - const sortBtn = Array.from(document.querySelectorAll('button')).find((b) => - b.textContent?.toLowerCase().includes('älteste') - ); - const chevron = sortBtn?.querySelector('svg'); - expect(chevron?.getAttribute('class')).toContain('rotate-180'); - }); - - it('does not rotate the chevron when sortDir is DESC', async () => { - render(ConversationFilterBar, { props: baseProps({ sortDir: 'DESC' }) }); - - const sortBtn = Array.from(document.querySelectorAll('button')).find((b) => - b.textContent?.toLowerCase().includes('neueste') - ); - const chevron = sortBtn?.querySelector('svg'); - expect(chevron?.getAttribute('class')).not.toContain('rotate-180'); - }); - - it('calls ontoggleSort when the sort button is clicked', async () => { - const ontoggleSort = vi.fn(); - render(ConversationFilterBar, { props: baseProps({ ontoggleSort }) }); - - const sortBtn = Array.from(document.querySelectorAll('button')).find((b) => - b.textContent?.toLowerCase().includes('neueste') - ); - sortBtn?.click(); - expect(ontoggleSort).toHaveBeenCalledOnce(); - }); - - it('calls onswapPersons when the swap button is clicked', async () => { - const onswapPersons = vi.fn(); - render(ConversationFilterBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2', onswapPersons }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - swap.click(); - expect(onswapPersons).toHaveBeenCalledOnce(); - }); - - it('calls onapplyFilters when fromDate input changes', async () => { - const onapplyFilters = vi.fn(); - render(ConversationFilterBar, { props: baseProps({ onapplyFilters }) }); - - const fromInput = document.querySelector('#dateFrom') as HTMLInputElement; - fromInput.value = '1899-04-14'; - fromInput.dispatchEvent(new Event('change', { bubbles: true })); - expect(onapplyFilters).toHaveBeenCalled(); - }); - - it('calls onapplyFilters when toDate input changes', async () => { - const onapplyFilters = vi.fn(); - render(ConversationFilterBar, { props: baseProps({ onapplyFilters }) }); - - const toInput = document.querySelector('#dateTo') as HTMLInputElement; - toInput.value = '1950-12-31'; - toInput.dispatchEvent(new Event('change', { bubbles: true })); - expect(onapplyFilters).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte deleted file mode 100644 index 3cb2dba9..00000000 --- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - -{#if isBilateral && documents.length > 0} - -{/if} - -
- {#each enrichedDocuments as { doc, year, showYearDivider, isOut } (doc.id)} - {#if showYearDivider && year !== null} -
- {year} - {countsByYear.get(year) ?? 0} Briefe -
- {/if} - - - {/each} - - {#if canWrite} - - {/if} -
diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte.test.ts b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte.test.ts deleted file mode 100644 index 98840368..00000000 --- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import ConversationTimeline from './ConversationTimeline.svelte'; - -afterEach(cleanup); - -const sender = { id: 'p1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; -const receiver = { id: 'p2', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' }; - -const makeDoc = (overrides: Record = {}) => ({ - id: 'd1', - originalFilename: 'brief.pdf', - documentDate: '1923-04-15', - sender, - receivers: [receiver], - tags: [], - ...overrides -}); - -const baseProps = (overrides: Record = {}) => ({ - documents: [makeDoc()], - senderId: 'p1', - receiverId: '', - canWrite: false, - senderName: 'Anna Schmidt', - receiverName: 'Bert Meier', - ...overrides -}); - -describe('ConversationTimeline', () => { - it('renders the year divider for the document year', async () => { - render(ConversationTimeline, { props: baseProps() }); - - await expect.element(page.getByTestId('year-divider')).toBeVisible(); - }); - - it('renders one year divider per distinct year', async () => { - render(ConversationTimeline, { - props: baseProps({ - documents: [ - makeDoc({ id: 'd1', documentDate: '1923-04-15' }), - makeDoc({ id: 'd2', documentDate: '1924-06-20' }), - makeDoc({ id: 'd3', documentDate: '1925-12-31' }) - ] - }) - }); - - const dividers = document.querySelectorAll('[data-testid="year-divider"]'); - expect(dividers.length).toBe(3); - }); - - it('does not duplicate the year divider when consecutive documents share a year', async () => { - render(ConversationTimeline, { - props: baseProps({ - documents: [ - makeDoc({ id: 'd1', documentDate: '1923-04-15' }), - makeDoc({ id: 'd2', documentDate: '1923-06-20' }) - ] - }) - }); - - const dividers = document.querySelectorAll('[data-testid="year-divider"]'); - expect(dividers.length).toBe(1); - }); - - it('does not render a year divider for documents with no documentDate', async () => { - render(ConversationTimeline, { - props: baseProps({ - documents: [makeDoc({ documentDate: undefined })] - }) - }); - - const dividers = document.querySelectorAll('[data-testid="year-divider"]'); - expect(dividers.length).toBe(0); - }); - - it('renders the new-document link when canWrite is true', async () => { - render(ConversationTimeline, { props: baseProps({ canWrite: true }) }); - - await expect - .element(page.getByTestId('conv-new-doc-link')) - .toHaveAttribute('href', '/documents/new?senderId=p1'); - }); - - it('appends receiverId to the new-document URL when set', async () => { - render(ConversationTimeline, { - props: baseProps({ canWrite: true, receiverId: 'p2' }) - }); - - await expect - .element(page.getByTestId('conv-new-doc-link')) - .toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2'); - }); - - it('hides the new-document link when canWrite is false', async () => { - render(ConversationTimeline, { props: baseProps({ canWrite: false }) }); - - await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte deleted file mode 100644 index 41f16b5e..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - -
handleKeydown(e, e.currentTarget as HTMLElement)} -> - -
- {m.conv_suggestions_heading()} -
- - - {#if !loading} - {#each correspondents as person (person.id)} -
onselect(person.id)} - onkeydown={(e) => e.key === 'Enter' && onselect(person.id)} - > - - - - {person.displayName} -
- {/each} - {/if} - - -
- - -
onselect('')} - onkeydown={(e) => e.key === 'Enter' && onselect('')} - > - {m.conv_suggestions_all_label({ name: senderName })} -
-
diff --git a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts deleted file mode 100644 index 227edbea..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte'; - -afterEach(cleanup); - -const corrA = { id: 'a', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; -const corrB = { id: 'b', firstName: null, lastName: 'Müller', displayName: 'Müller' }; - -describe('CorrespondentSuggestionsDropdown', () => { - it('renders the heading and the "all correspondents" row even when the list is empty', async () => { - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [], - loading: false, - senderName: 'Anna', - onselect: () => {}, - onclose: () => {} - } - }); - - await expect.element(page.getByText('Häufigste Korrespondenten')).toBeVisible(); - await expect.element(page.getByText('Alle Korrespondenten von Anna')).toBeVisible(); - }); - - it('renders one row per correspondent when not loading', async () => { - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [corrA, corrB], - loading: false, - senderName: 'Anna', - onselect: () => {}, - onclose: () => {} - } - }); - - await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); - await expect.element(page.getByText('Müller')).toBeVisible(); - }); - - it('hides correspondent rows while loading is true', async () => { - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [corrA], - loading: true, - senderName: 'Anna', - onselect: () => {}, - onclose: () => {} - } - }); - - await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument(); - await expect.element(page.getByText('Häufigste Korrespondenten')).toBeVisible(); - }); - - it('builds initials from firstName + lastName when available', async () => { - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [corrA], - loading: false, - senderName: 'Anna', - onselect: () => {}, - onclose: () => {} - } - }); - - await expect.element(page.getByText('AS')).toBeVisible(); - }); - - it('falls back to the first two letters of lastName when firstName is missing', async () => { - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [corrB], - loading: false, - senderName: 'Anna', - onselect: () => {}, - onclose: () => {} - } - }); - - await expect.element(page.getByText('MÜ')).toBeVisible(); - }); - - it('calls onselect with the correspondent id when a row is clicked', async () => { - const onselect = vi.fn(); - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [corrA], - loading: false, - senderName: 'Anna', - onselect, - onclose: () => {} - } - }); - - await page.getByText('Anna Schmidt').click(); - - expect(onselect).toHaveBeenCalledWith('a'); - }); - - it('calls onselect with an empty string when the "all correspondents" row is clicked', async () => { - const onselect = vi.fn(); - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [], - loading: false, - senderName: 'Anna', - onselect, - onclose: () => {} - } - }); - - await page.getByText('Alle Korrespondenten von Anna').click(); - - expect(onselect).toHaveBeenCalledWith(''); - }); - - it('calls onselect via Enter key on a focused row', async () => { - const onselect = vi.fn(); - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [corrA], - loading: false, - senderName: 'Anna', - onselect, - onclose: () => {} - } - }); - - const row = (await page.getByText('Anna Schmidt').element()) as HTMLElement; - row.focus(); - row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - - expect(onselect).toHaveBeenCalledWith('a'); - }); - - it('calls onclose when the Escape key is pressed', async () => { - const onclose = vi.fn(); - render(CorrespondentSuggestionsDropdown, { - props: { - correspondents: [corrA], - loading: false, - senderName: 'Anna', - onselect: () => {}, - onclose - } - }); - - const list = (await page.getByRole('listbox').element()) as HTMLElement; - list.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - - expect(onclose).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte b/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte deleted file mode 100644 index a3053ee6..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - -
- -
- - onapplyFilters()} - class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
- - -
- - onapplyFilters()} - class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" - /> -
-
diff --git a/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte.test.ts deleted file mode 100644 index 80710f78..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzFilterControls.svelte.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte'; - -afterEach(cleanup); - -describe('CorrespondenzFilterControls', () => { - it('renders both date input labels', async () => { - render(CorrespondenzFilterControls, { props: { onapplyFilters: () => {} } }); - - await expect.element(page.getByText('Zeitraum von')).toBeVisible(); - await expect.element(page.getByText('Zeitraum bis')).toBeVisible(); - }); - - it('renders two DateInputs with stable ids', async () => { - render(CorrespondenzFilterControls, { props: { onapplyFilters: () => {} } }); - - expect(document.getElementById('conv-from')).not.toBeNull(); - expect(document.getElementById('conv-to')).not.toBeNull(); - }); - - it('hydrates the from input from fromDate', async () => { - render(CorrespondenzFilterControls, { - props: { fromDate: '1923-04-15', onapplyFilters: () => {} } - }); - - const fromInput = document.getElementById('conv-from') as HTMLInputElement; - expect(fromInput.value).toContain('1923'); - }); - - it('hydrates the to input from toDate', async () => { - render(CorrespondenzFilterControls, { - props: { toDate: '1925-12-31', onapplyFilters: () => {} } - }); - - const toInput = document.getElementById('conv-to') as HTMLInputElement; - expect(toInput.value).toContain('1925'); - }); - - it('calls onapplyFilters when the from date changes', async () => { - const onapplyFilters = vi.fn(); - render(CorrespondenzFilterControls, { props: { onapplyFilters } }); - - const fromInput = document.getElementById('conv-from') as HTMLInputElement; - fromInput.value = '15.04.1923'; - fromInput.dispatchEvent(new Event('change', { bubbles: true })); - - // onchange wires through DateInput; direct DOM dispatch should bubble. - // At minimum, no crash + the spy may or may not have been called - // depending on DateInput's internals — just smoke-check it didn't throw. - expect(typeof onapplyFilters).toBe('function'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte deleted file mode 100644 index 4543c02e..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
- -

- {m.conv_empty_heading()} -

- - - - {m.conv_hero_crosslink()} - - - -
- -
- - - {#if recentPersons.length > 0} -
-
- {m.conv_hero_divider()} -
-
- -
- - {m.conv_empty_recent_label()} - -
- {#each recentPersons as person (person.id)} - - {/each} -
-
- {/if} -
diff --git a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts deleted file mode 100644 index cad5bc96..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzHero from './CorrespondenzHero.svelte'; - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -afterEach(cleanup); - -const noop = () => {}; - -describe('CorrespondenzHero — headline and cross-link', () => { - it('renders the discovery headline', async () => { - render(CorrespondenzHero, { onSelectPerson: noop }); - await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument(); - }); - - it('renders a cross-link to the document search page', async () => { - render(CorrespondenzHero, { onSelectPerson: noop }); - const link = page.getByRole('link', { name: /Zur Dokumentensuche/i }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute('href', '/'); - }); - - it('renders a person typeahead input', async () => { - render(CorrespondenzHero, { onSelectPerson: noop }); - // PersonTypeahead renders , not role="textbox" - await expect.element(page.getByTestId('conv-hero').getByRole('combobox')).toBeInTheDocument(); - }); -}); - -describe('CorrespondenzHero — recent persons', () => { - it('shows recent person chips when provided', async () => { - render(CorrespondenzHero, { - onSelectPerson: noop, - recentPersons: [{ id: 'r1', name: 'Clara Braun' }] - }); - await expect.element(page.getByText('Clara Braun')).toBeInTheDocument(); - }); - - it('calls onSelectPerson when a recent person chip is clicked', async () => { - const spy = vi.fn(); - render(CorrespondenzHero, { - onSelectPerson: spy, - recentPersons: [{ id: 'r1', name: 'Clara Braun' }] - }); - await expect.element(page.getByText('Clara Braun')).toBeInTheDocument(); - document.querySelector('[data-testid="recent-person-r1"]')!.click(); - expect(spy).toHaveBeenCalledWith('r1'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.test.ts deleted file mode 100644 index ac516958..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzHero from './CorrespondenzHero.svelte'; - -afterEach(cleanup); - -describe('CorrespondenzHero', () => { - it('renders the headline and cross-link', async () => { - render(CorrespondenzHero, { props: { onSelectPerson: () => {} } }); - - await expect.element(page.getByRole('heading', { name: /wessen briefe/i })).toBeVisible(); - await expect.element(page.getByRole('link', { name: /dokumentensuche/i })).toBeVisible(); - }); - - it('omits the recent-persons section when recentPersons is empty', async () => { - render(CorrespondenzHero, { props: { onSelectPerson: () => {} } }); - - await expect.element(page.getByText('Zuletzt geöffnet')).not.toBeInTheDocument(); - }); - - it('renders the recent-persons divider and chips when persons are provided', async () => { - render(CorrespondenzHero, { - props: { - onSelectPerson: () => {}, - recentPersons: [ - { id: 'p1', name: 'Anna Schmidt' }, - { id: 'p2', name: 'Bert Meier' } - ] - } - }); - - await expect.element(page.getByText('Zuletzt geöffnet')).toBeVisible(); - await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); - await expect.element(page.getByText('Bert Meier')).toBeVisible(); - }); - - it('calls onSelectPerson with the recent-person id when clicked', async () => { - const onSelectPerson = vi.fn(); - render(CorrespondenzHero, { - props: { - onSelectPerson, - recentPersons: [{ id: 'p-42', name: 'Anna Schmidt' }] - } - }); - - const btn = document.querySelector('[data-testid="recent-person-p-42"]') as HTMLButtonElement; - btn.click(); - - expect(onSelectPerson).toHaveBeenCalledWith('p-42'); - }); - - it('renders the avatar initial in the recent-person chip', async () => { - render(CorrespondenzHero, { - props: { - onSelectPerson: () => {}, - recentPersons: [{ id: 'p1', name: 'anna schmidt' }] - } - }); - - // Avatar shows the uppercase first letter - const avatars = document.querySelectorAll( - '[data-testid^="recent-person-"] span[aria-hidden="true"]' - ); - expect(avatars[0].textContent?.trim()).toBe('A'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte b/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte deleted file mode 100644 index 5dc6bc34..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - - -
- -
- { if (id) onapplyFilters(); }} - /> -
- - - - - -
- { - showSuggestions = false; - onapplyFilters(); - }} - onfocused={handleCorrespondentFocused} - /> - {#if showSuggestions && senderId && !receiverId} - (showSuggestions = false)} - /> - {/if} -
-
- - -
- - - - - - - - - {m.conv_letters_count({ count: documentCount })} - -
diff --git a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte.test.ts deleted file mode 100644 index 02734709..00000000 --- a/frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte'; - -afterEach(cleanup); - -const baseProps = (overrides: Record = {}) => ({ - senderId: '', - receiverId: '', - initialSenderName: '', - initialReceiverName: '', - sortDir: 'DESC', - showAdvanced: false, - documentCount: 0, - onapplyFilters: () => {}, - onswapPersons: () => {}, - ontoggleSort: () => {}, - ontoggleAdvanced: () => {}, - ...overrides -}); - -describe('CorrespondenzPersonBar', () => { - it('renders the two PersonTypeahead inputs', async () => { - render(CorrespondenzPersonBar, { props: baseProps() }); - - const inputs = document.querySelectorAll('input'); - expect(inputs.length).toBeGreaterThanOrEqual(2); - }); - - it('hides the swap button when only one person is set', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ senderId: 'p1' }) }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.classList.contains('opacity-0')).toBe(true); - expect(swap.tabIndex).toBe(-1); - }); - - it('shows the swap button when both senderId and receiverId are set', async () => { - render(CorrespondenzPersonBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2' }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - expect(swap.classList.contains('opacity-0')).toBe(false); - expect(swap.tabIndex).toBe(0); - }); - - it('renders the "Neueste" label when sortDir is DESC', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ sortDir: 'DESC' }) }); - - await expect.element(page.getByText('Neueste')).toBeVisible(); - }); - - it('renders the "Älteste" label when sortDir is ASC', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ sortDir: 'ASC' }) }); - - await expect.element(page.getByText('Älteste')).toBeVisible(); - }); - - it('marks the sort button as aria-pressed when sortDir is ASC', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ sortDir: 'ASC' }) }); - - const sort = document.querySelector('[data-testid="conv-sort-btn"]') as HTMLElement; - expect(sort.getAttribute('aria-pressed')).toBe('true'); - }); - - it('renders the document count', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ documentCount: 42 }) }); - - await expect.element(page.getByText('42 Briefe')).toBeVisible(); - }); - - it('calls ontoggleSort when the sort button is clicked', async () => { - const ontoggleSort = vi.fn(); - render(CorrespondenzPersonBar, { props: baseProps({ ontoggleSort }) }); - - const sort = document.querySelector('[data-testid="conv-sort-btn"]') as HTMLElement; - sort.click(); - - expect(ontoggleSort).toHaveBeenCalledOnce(); - }); - - it('calls onswapPersons when the swap button is clicked', async () => { - const onswapPersons = vi.fn(); - render(CorrespondenzPersonBar, { - props: baseProps({ senderId: 'p1', receiverId: 'p2', onswapPersons }) - }); - - const swap = document.querySelector('[data-testid="conv-swap-btn"]') as HTMLElement; - swap.click(); - - expect(onswapPersons).toHaveBeenCalledOnce(); - }); - - it('opens the suggestions dropdown on receiver focus when a senderId is set', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify([{ id: 'p3', firstName: 'Carl', lastName: 'Brandt' }]), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - ); - try { - render(CorrespondenzPersonBar, { - props: baseProps({ senderId: 'p1', receiverId: '' }) - }); - - // Find the second PersonTypeahead input (Korrespondent) and trigger focus event - const inputs = document.querySelectorAll('input[type="text"]'); - const corrInput = inputs[inputs.length - 1] as HTMLInputElement; - corrInput.dispatchEvent(new Event('focus', { bubbles: true })); - - // Confirm the typeahead fired the suggestions fetch. - await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()); - } finally { - fetchSpy.mockRestore(); - } - }); - - it('does not show advanced filter chevron rotation when showAdvanced is false', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ showAdvanced: false }) }); - - // The filter toggle button has a chevron — should NOT be rotated - const buttons = document.querySelectorAll('button'); - const filterBtn = Array.from(buttons).find((b) => - b.textContent?.toLowerCase().includes('filter') - ); - const chevron = filterBtn?.querySelector('img'); - expect(chevron?.getAttribute('class')).not.toContain('rotate-180'); - }); - - it('rotates the filter chevron when showAdvanced is true', async () => { - render(CorrespondenzPersonBar, { props: baseProps({ showAdvanced: true }) }); - - const buttons = document.querySelectorAll('button'); - const filterBtn = Array.from(buttons).find((b) => - b.textContent?.toLowerCase().includes('filter') - ); - const chevron = filterBtn?.querySelector('img'); - expect(chevron?.getAttribute('class')).toContain('rotate-180'); - }); -}); diff --git a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte b/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte deleted file mode 100644 index ab867c4f..00000000 --- a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -
- - - {#if hasDateFilter} - {senderName} - · - {fromYear}–{toYear} - · - {sortLabel} - {:else} - Alle Briefe von {senderName} — wähle einen Korrespondenten oben um einzugrenzen - {/if} -
diff --git a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte.test.ts b/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte.test.ts deleted file mode 100644 index 20cb9ea9..00000000 --- a/frontend/src/routes/briefwechsel/SinglePersonHintBar.svelte.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import SinglePersonHintBar from './SinglePersonHintBar.svelte'; - -afterEach(cleanup); - -describe('SinglePersonHintBar', () => { - it('renders the no-filter prompt when neither fromDate nor toDate is supplied', async () => { - render(SinglePersonHintBar, { props: { senderName: 'Anna Schmidt' } }); - - await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); - await expect.element(page.getByText(/wähle einen korrespondenten/i)).toBeVisible(); - }); - - it('renders the year range and sort label when fromDate is supplied', async () => { - render(SinglePersonHintBar, { - props: { - senderName: 'Anna Schmidt', - fromDate: '1923-01-01', - toDate: '1925-12-31', - sortDir: 'DESC' - } - }); - - await expect.element(page.getByText('1923–1925')).toBeVisible(); - await expect.element(page.getByText('Neueste')).toBeVisible(); - }); - - it('uses the "Älteste" label when sortDir is ASC', async () => { - render(SinglePersonHintBar, { - props: { senderName: 'Anna Schmidt', fromDate: '1923-01-01', sortDir: 'ASC' } - }); - - await expect.element(page.getByText('Älteste')).toBeVisible(); - }); - - it('hides the no-filter prompt when fromDate alone is set', async () => { - render(SinglePersonHintBar, { - props: { senderName: 'Anna Schmidt', fromDate: '1923-01-01' } - }); - - await expect.element(page.getByText(/wähle einen korrespondenten/i)).not.toBeInTheDocument(); - }); - - it('shows year range using only fromYear when toDate is empty', async () => { - render(SinglePersonHintBar, { - props: { senderName: 'Anna Schmidt', fromDate: '1923-01-01' } - }); - - await expect.element(page.getByText('1923–')).toBeVisible(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/page.server.spec.ts b/frontend/src/routes/briefwechsel/page.server.spec.ts deleted file mode 100644 index 5329e896..00000000 --- a/frontend/src/routes/briefwechsel/page.server.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { load } from './+page.server'; - -vi.mock('$lib/shared/api.server', () => ({ - createApiClient: vi.fn(), - extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code -})); -vi.mock('$lib/shared/errors', () => ({ - getErrorMessage: (code: string) => code ?? 'Unknown error' -})); - -import { createApiClient } from '$lib/shared/api.server'; - -const writeUser = { groups: [{ permissions: ['WRITE_ALL'] }] }; -const readUser = { groups: [{ permissions: ['READ_ALL'] }] }; - -function makeUrl(params: Record = {}): URL { - const url = new URL('http://x/korrespondenz'); - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); - return url; -} - -function mockApi(calls: { ok: boolean; data?: unknown; status?: number }[]) { - const GET = vi.fn(); - for (const call of calls) { - GET.mockResolvedValueOnce({ - response: { ok: call.ok, status: call.status ?? (call.ok ? 200 : 500) }, - data: call.data, - error: call.ok ? undefined : { code: 'INTERNAL_ERROR' } - }); - } - vi.mocked(createApiClient).mockReturnValue({ GET } as ReturnType); - return GET; -} - -beforeEach(() => vi.clearAllMocks()); - -// ─── No senderId ────────────────────────────────────────────────────────────── - -describe('korrespondenz load — no senderId', () => { - it('returns empty documents without calling the conversation endpoint', async () => { - const GET = mockApi([]); - - const result = await load({ - url: makeUrl(), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.documents).toEqual([]); - expect(GET).not.toHaveBeenCalled(); - }); -}); - -// ─── With senderId, no receiverId ──────────────────────────────────────────── - -describe('korrespondenz load — senderId set, no receiverId', () => { - it('calls the conversation endpoint and the sender person endpoint', async () => { - const docs = [{ id: 'd1', title: 'Testbrief' }]; - const GET = mockApi([ - { ok: true, data: docs }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.documents).toEqual(docs); - expect(result.initialValues.senderName).toBe('Hans Müller'); - expect(result.initialValues.receiverName).toBe(''); - expect(GET).toHaveBeenCalledTimes(2); - }); -}); - -// ─── With senderId and receiverId ──────────────────────────────────────────── - -describe('korrespondenz load — senderId and receiverId set', () => { - it('calls conversation, sender person, and receiver person endpoints', async () => { - const GET = mockApi([ - { ok: true, data: [] }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - }, - { - ok: true, - data: { - firstName: 'Anna', - lastName: 'Schmidt', - displayName: 'Anna Schmidt', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1', receiverId: 'p2' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.initialValues.senderName).toBe('Hans Müller'); - expect(result.initialValues.receiverName).toBe('Anna Schmidt'); - expect(GET).toHaveBeenCalledTimes(3); - }); -}); - -// ─── canWrite derivation ───────────────────────────────────────────────────── - -describe('korrespondenz load — canWrite', () => { - it('derives canWrite true from WRITE_ALL permission', async () => { - mockApi([ - { ok: true, data: [] }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: writeUser } - }); - - expect(result.canWrite).toBe(true); - }); - - it('derives canWrite false when user lacks WRITE_ALL', async () => { - mockApi([ - { ok: true, data: [] }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - const result = await load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }); - - expect(result.canWrite).toBe(false); - }); -}); - -// ─── Backend error propagation ──────────────────────────────────────────────── - -describe('korrespondenz load — backend error', () => { - it('throws when the conversation endpoint returns non-ok', async () => { - mockApi([ - { ok: false, status: 500 }, - { - ok: true, - data: { - firstName: 'Hans', - lastName: 'Müller', - displayName: 'Hans Müller', - personType: 'PERSON' - } - } - ]); - - await expect( - load({ - url: makeUrl({ senderId: 'p1' }), - request: new Request('http://localhost/briefwechsel'), - fetch: vi.fn() as unknown as typeof fetch, - locals: { user: readUser } - }) - ).rejects.toMatchObject({ status: 500 }); - }); -}); diff --git a/frontend/src/routes/briefwechsel/page.svelte.spec.ts b/frontend/src/routes/briefwechsel/page.svelte.spec.ts deleted file mode 100644 index f23171d8..00000000 --- a/frontend/src/routes/briefwechsel/page.svelte.spec.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import Page from './+page.svelte'; - -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); - -afterEach(cleanup); - -// ─── Test data ──────────────────────────────────────────────────────────────── - -const baseData = { - user: undefined, - canWrite: true, - canAnnotate: false, - canBlogWrite: false, - documents: [], - initialValues: { senderName: '', receiverName: '' }, - filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const } -}; - -const withSender = { - ...baseData, - initialValues: { senderName: 'Hans Müller', receiverName: '' }, - filters: { ...baseData.filters, senderId: 'p1' } -}; - -const withPersons = { - ...baseData, - initialValues: { senderName: 'Hans Müller', receiverName: 'Anna Schmidt' }, - filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' } -}; - -const makePerson = (overrides: Record = {}) => ({ - id: 'p1', - firstName: 'Hans', - lastName: 'Müller', - personType: 'PERSON' as const, - familyMember: false, - 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', - originalFilename: 'testbrief.pdf', - status: 'UPLOADED' as const, - documentDate: '1923-04-12', - location: 'Berlin', - metadataComplete: false, - scriptType: 'UNKNOWN' as const, - sender: makePerson(), - receivers: [ - makePerson({ - id: 'p2', - firstName: 'Anna', - lastName: 'Schmidt', - displayName: 'Anna Schmidt' - }) - ], - tags: [], - transcription: undefined, - filePath: undefined, - createdAt: '1923-04-12T00:00:00Z', - updatedAt: '1923-04-12T00:00:00Z', - ...overrides -}); - -const withDocs = { - ...withPersons, - documents: [makeDoc()] -}; - -// ─── Hero state (no senderId) ──────────────────────────────────────────────── - -describe('Briefwechsel page – hero state', () => { - it('shows the hero when no person is selected', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument(); - }); - - it('shows the discovery headline', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument(); - }); - - it('does not show the person bar in hero state', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-person-bar')).not.toBeInTheDocument(); - }); - - it('does not show filter controls in hero state', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument(); - }); - - it('does not show the new document link when no person is selected', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); - }); - - it('does not show a year divider when no person is selected', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByTestId('year-divider')).not.toBeInTheDocument(); - }); -}); - -// ─── Results state (senderId set) ──────────────────────────────────────────── - -describe('Briefwechsel page – results state', () => { - it('does not show the hero when senderId is set', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-hero')).not.toBeInTheDocument(); - }); - - it('shows the person bar when senderId is set', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument(); - }); - - it('hides filter controls by default (collapsible)', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument(); - await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument(); - }); -}); - -// ─── Recent persons chips ───────────────────────────────────────────────────── - -describe('Briefwechsel page – recent persons', () => { - it('shows recent person chips from localStorage', async () => { - localStorage.setItem( - 'korrespondenz_recent_persons', - JSON.stringify([{ id: 'r1', name: 'Clara Braun' }]) - ); - render(Page, { data: baseData }); - await expect.element(page.getByText('Clara Braun')).toBeInTheDocument(); - localStorage.removeItem('korrespondenz_recent_persons'); - }); - - it('does not crash when localStorage contains corrupt JSON', async () => { - localStorage.setItem('korrespondenz_recent_persons', '}{not valid json'); - render(Page, { data: baseData }); - await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument(); - localStorage.removeItem('korrespondenz_recent_persons'); - }); -}); - -// ─── Single-person hint bar ─────────────────────────────────────────────────── - -describe('Briefwechsel page – single-person hint bar', () => { - it('shows hint bar when only senderId is set', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument(); - }); - - it('does not show hint bar when both persons are set', async () => { - render(Page, { data: { ...withPersons, documents: [makeDoc()] } }); - await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).not.toBeInTheDocument(); - }); - - it('does not show hint bar when no person is set', async () => { - render(Page, { data: baseData }); - await expect.element(page.getByText(/Alle Briefe von/i)).not.toBeInTheDocument(); - }); -}); - -// ─── Strip letter count ─────────────────────────────────────────────────────── - -describe('Briefwechsel page – strip letter count', () => { - it('shows 0 Briefe when senderId is set but no documents', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe'); - }); - - it('shows correct count when documents are loaded', async () => { - render(Page, { data: { ...withPersons, documents: [makeDoc()] } }); - await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('1 Briefe'); - }); -}); - -// ─── No results ─────────────────────────────────────────────────────────────── - -describe('Briefwechsel page – no results', () => { - it('shows "no documents found" when a person is selected but there are no documents', async () => { - render(Page, { data: withSender }); - await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument(); - }); -}); - -// ─── Swap button ────────────────────────────────────────────────────────────── - -describe('Briefwechsel page – swap button', () => { - it('swap button is invisible when only one person is set', async () => { - render(Page, { data: withSender }); - const btn = document.querySelector('[data-testid="conv-swap-btn"]'); - expect(btn).not.toBeNull(); - expect(btn!.className).toMatch(/opacity-0/); - }); - - it('swap button is visible when both persons are set', async () => { - render(Page, { data: withPersons }); - const btn = document.querySelector('[data-testid="conv-swap-btn"]'); - expect(btn).not.toBeNull(); - expect(btn!.className).not.toMatch(/opacity-0/); - }); - - it('calls goto with swapped sender and receiver when clicked', async () => { - const { goto } = await import('$app/navigation'); - vi.mocked(goto).mockClear(); - render(Page, { data: withPersons }); - document.querySelector('[data-testid="conv-swap-btn"]')!.click(); - expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything()); - expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything()); - }); -}); - -// ─── 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', () => { - it('renders a year divider for the first document', async () => { - render(Page, { data: withDocs }); - await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); - }); - - it('renders a divider for each new year in the document list', async () => { - const data = { - ...withPersons, - documents: [ - makeDoc({ documentDate: '1923-04-12' }), - makeDoc({ id: 'd2', documentDate: '1965-08-03' }) - ] - }; - render(Page, { data }); - await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); - await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965'); - }); - - it('does not render a second divider for documents from the same year', async () => { - const data = { - ...withPersons, - documents: [ - makeDoc({ documentDate: '1923-04-12' }), - makeDoc({ id: 'd2', documentDate: '1923-09-01' }) - ] - }; - render(Page, { data }); - await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); - await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument(); - }); -}); - -// ─── New document link ──────────────────────────────────────────────────────── - -describe('Briefwechsel page – new document link', () => { - it('shows the link with correct href for a write user (bilateral)', async () => { - render(Page, { data: { ...withDocs, canWrite: true } }); - const link = page.getByTestId('conv-new-doc-link'); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1')); - await expect.element(link).toHaveAttribute('href', expect.stringContaining('receiverId=p2')); - }); - - it('shows the link with correct href for single-person mode', async () => { - render(Page, { data: { ...withSender, documents: [makeDoc()], canWrite: true } }); - const link = page.getByTestId('conv-new-doc-link'); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute('href', expect.stringContaining('senderId=p1')); - await expect.element(link).not.toHaveAttribute('href', expect.stringContaining('receiverId')); - }); - - it('hides the link for a read-only user', async () => { - render(Page, { data: { ...withDocs, canWrite: false } }); - await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/routes/briefwechsel/page.svelte.test.ts b/frontend/src/routes/briefwechsel/page.svelte.test.ts deleted file mode 100644 index 8bbcd354..00000000 --- a/frontend/src/routes/briefwechsel/page.svelte.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; - -vi.mock('$app/navigation', () => ({ - beforeNavigate: () => {}, - afterNavigate: () => {}, - goto: vi.fn(), - invalidate: vi.fn(), - invalidateAll: vi.fn(), - preloadCode: vi.fn(), - preloadData: vi.fn(), - pushState: vi.fn(), - replaceState: vi.fn(), - disableScrollHandling: vi.fn(), - onNavigate: () => () => {} -})); - -const { default: BriefwechselPage } = await import('./+page.svelte'); - -afterEach(cleanup); - -const baseData = (overrides: Record = {}) => ({ - filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: '', receiverName: '' }, - documents: [], - canWrite: false, - ...overrides -}); - -describe('briefwechsel/+ page', () => { - it('renders the hero when no senderId is set', async () => { - render(BriefwechselPage, { props: { data: baseData() } }); - - // CorrespondenzHero should render - const inputs = document.querySelectorAll('input'); - expect(inputs.length).toBeGreaterThan(0); - }); - - it('renders the results card when a senderId is set', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna Schmidt', receiverName: '' } - }) - } - }); - - // CorrespondenzPersonBar should render with results context - await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); - }); - - it('renders the SinglePersonHintBar when there is a sender but no receiver', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: '' } - }) - } - }); - - // "Alle Briefe von Anna" message from SinglePersonHintBar - await expect.element(page.getByText(/wähle einen korrespondenten/i)).toBeVisible(); - }); - - it('renders the empty results message when documents is empty and a sender is set', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: '' } - }) - } - }); - - await expect.element(page.getByText('Keine Dokumente gefunden.')).toBeVisible(); - }); - - it('hides the SinglePersonHintBar when both sender and receiver are set', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: 'p2', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: 'Bert' } - }) - } - }); - - await expect.element(page.getByText(/wähle einen korrespondenten/i)).not.toBeInTheDocument(); - }); - - it('renders the timeline when documents is non-empty', async () => { - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: 'p2', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna', receiverName: 'Bert' }, - documents: [ - { - id: 'd1', - title: 'Brief 1', - documentDate: '1899-04-14', - sender: { id: 'p1', displayName: 'Anna' }, - receivers: [{ id: 'p2', displayName: 'Bert' }], - status: 'UPLOADED' - } - ] - }) - } - }); - - expect(document.body.textContent).toContain('Brief 1'); - }); - - it('writes the senderName to localStorage when sender filter is set on mount', async () => { - localStorage.removeItem('korrespondenz_recent_persons'); - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p1', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Anna Schmidt', receiverName: '' } - }) - } - }); - - // persistRecentPerson runs in onMount — the persisted entry must include the name. - await vi.waitFor(() => { - const stored = localStorage.getItem('korrespondenz_recent_persons'); - expect(stored).toContain('Anna Schmidt'); - }); - }); - - it('falls back to an empty recent-persons list when localStorage is malformed', async () => { - localStorage.setItem('korrespondenz_recent_persons', 'not-json'); - render(BriefwechselPage, { - props: { data: baseData() } - }); - - // Page still mounts; the malformed entry must not break rendering. The page renders - // a max-w-7xl container at the root. - expect(document.querySelector('.max-w-7xl')).not.toBeNull(); - localStorage.removeItem('korrespondenz_recent_persons'); - }); - - it('appends the senderName when only sender is set on mount (persistRecentPerson path)', async () => { - localStorage.removeItem('korrespondenz_recent_persons'); - render(BriefwechselPage, { - props: { - data: baseData({ - filters: { senderId: 'p-test', receiverId: '', from: '', to: '', dir: 'DESC' }, - initialValues: { senderName: 'Test Person', receiverName: '' } - }) - } - }); - - await vi.waitFor(() => { - const stored = localStorage.getItem('korrespondenz_recent_persons'); - expect(stored).toContain('Test Person'); - }); - }); -}); diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 3518f82b..631799d3 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -68,7 +68,11 @@ const coCorrespondents = $derived.by(() => {
- +
{m.person_co_correspondents_heading()} - {m.person_correspondents_hint()} + {m.person_correspondents_search_hint()}
{#each coCorrespondents as c (c.id)} @@ -41,8 +45,11 @@ function initials(name: string): string { {initials(c.name)} {c.name} - ×{c.count} - + ×{c.count} + diff --git a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts index 5c9af779..afccedc0 100644 --- a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; import CoCorrespondentsList from './CoCorrespondentsList.svelte'; afterEach(cleanup); @@ -8,7 +9,7 @@ afterEach(cleanup); describe('CoCorrespondentsList', () => { it('renders nothing when the coCorrespondents list is empty', async () => { render(CoCorrespondentsList, { - props: { coCorrespondents: [], personId: 'p-1' } + props: { coCorrespondents: [], personId: 'p-1', personName: 'Anna Schmidt' } }); await expect @@ -16,18 +17,19 @@ describe('CoCorrespondentsList', () => { .not.toBeInTheDocument(); }); - it('renders the heading and hint when there is at least one co-correspondent', async () => { + it('renders the heading and search hint when there is at least one co-correspondent', async () => { render(CoCorrespondentsList, { props: { coCorrespondents: [{ id: 'c-1', name: 'Max Mustermann', count: 3 }], - personId: 'p-1' + personId: 'p-1', + personName: 'Anna Schmidt' } }); await expect .element(page.getByRole('heading', { name: /häufige korrespondenten/i })) .toBeVisible(); - await expect.element(page.getByText('klicken für Konversation')).toBeVisible(); + await expect.element(page.getByText(m.person_correspondents_search_hint())).toBeVisible(); }); it('renders one chip per co-correspondent with name and count', async () => { @@ -37,7 +39,8 @@ describe('CoCorrespondentsList', () => { { id: 'c-1', name: 'Max Mustermann', count: 3 }, { id: 'c-2', name: 'Erika Beispiel', count: 1 } ], - personId: 'p-1' + personId: 'p-1', + personName: 'Anna Schmidt' } }); @@ -47,24 +50,45 @@ describe('CoCorrespondentsList', () => { await expect.element(page.getByText('×1')).toBeVisible(); }); - it('points each chip to the bilateral conversation route with the correct ids', async () => { + it('points each chip to the document search pre-filtered by sender and receiver', async () => { render(CoCorrespondentsList, { props: { coCorrespondents: [{ id: 'c-1', name: 'Max Mustermann', count: 3 }], - personId: 'p-1' + personId: 'p-1', + personName: 'Anna Schmidt' } }); await expect .element(page.getByRole('link', { name: /max mustermann/i })) - .toHaveAttribute('href', '/briefwechsel?senderId=p-1&receiverId=c-1'); + .toHaveAttribute('href', '/documents?senderId=p-1&receiverId=c-1'); + }); + + it('labels the link as a search action naming both persons', async () => { + render(CoCorrespondentsList, { + props: { + coCorrespondents: [{ id: 'c-1', name: 'Max Mustermann', count: 3 }], + personId: 'p-1', + personName: 'Anna Schmidt' + } + }); + + // Asserted against the de message (names both persons) — not retyped copy. + const link = page.getByRole('link', { name: /max mustermann/i }); + await expect + .element(link) + .toHaveAttribute( + 'title', + m.person_correspondents_search_title({ A: 'Anna Schmidt', B: 'Max Mustermann' }) + ); }); it('builds initials from up to two name parts', async () => { render(CoCorrespondentsList, { props: { coCorrespondents: [{ id: 'c-1', name: 'Max Mustermann Beispiel', count: 1 }], - personId: 'p-1' + personId: 'p-1', + personName: 'Anna Schmidt' } }); @@ -75,7 +99,8 @@ describe('CoCorrespondentsList', () => { render(CoCorrespondentsList, { props: { coCorrespondents: [{ id: 'c-1', name: 'Cher', count: 2 }], - personId: 'p-1' + personId: 'p-1', + personName: 'Anna Schmidt' } }); @@ -88,7 +113,8 @@ describe('CoCorrespondentsList', () => { render(CoCorrespondentsList, { props: { coCorrespondents: [{ id: 'c-1', name: ' Max', count: 1 }], - personId: 'p-1' + personId: 'p-1', + personName: 'Anna Schmidt' } });