Compare commits

..

2 Commits

Author SHA1 Message Date
Marcel
c641d704a8 merge: resolve conflict with origin/main + fix WCAG AA contrast + add API test
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m27s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 3m16s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
- Merge origin/main (resolved conflict in +page.svelte: use res.ok check from main)
- fix(transcription): bump button text from text-brand-navy/60 (3.83:1) to
  text-brand-navy/80 (6.75:1) to pass WCAG AA 4.5:1 for 12px text
- feat(api-tests): add Transcription.http with PUT /review-all entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:11:58 +02:00
Marcel
69ac183fe8 feat(transcription): add bulk "Alle als fertig markieren" action to transcription panel
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m31s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m1s
CI / Unit & Component Tests (pull_request) Failing after 3m13s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
Adds a single-transaction backend endpoint PUT /api/documents/{id}/transcription-blocks/review-all
that marks all blocks as reviewed atomically. Emits N individual BLOCK_REVIEWED audit events (one
per previously-unreviewed block). The frontend button is disabled (not hidden) when all blocks are
already reviewed, and shows a spinner during the operation.

Closes #345

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:53:47 +02:00
116 changed files with 207 additions and 12564 deletions

View File

@@ -6,7 +6,6 @@ import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -48,12 +47,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())

View File

@@ -34,13 +34,11 @@ public class PersonController {
private final DocumentService documentService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
return ResponseEntity.ok(personService.findAll(q));
}
@GetMapping("/{id}")
@RequirePermission(Permission.READ_ALL)
public Person getPerson(@PathVariable UUID id) {
return personService.getById(id);
}

View File

@@ -1,6 +1,5 @@
package org.raddatz.familienarchiv.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
@@ -46,7 +45,7 @@ public class TranscriptionBlockController {
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock(
@PathVariable UUID documentId,
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
@RequestBody CreateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.createBlock(documentId, dto, userId);
@@ -57,7 +56,7 @@ public class TranscriptionBlockController {
public TranscriptionBlock updateBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
@RequestBody UpdateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.updateBlock(documentId, blockId, dto, userId);

View File

@@ -1,21 +1,14 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateTranscriptionBlockDTO {
@Min(0)
private int pageNumber;
@@ -29,8 +22,4 @@ public class CreateTranscriptionBlockDTO {
private double height;
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -17,7 +17,6 @@ public interface PersonSummaryDTO {
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
boolean isFamilyMember();
long getDocumentCount();
default String getDisplayName() {

View File

@@ -1,24 +1,13 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UpdateTranscriptionBlockDTO {
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -15,10 +15,6 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
/** A concurrent edit on a referenced transcription block prevented the rename
* from committing (optimistic-lock conflict). The whole rename rolls back; the
* client should refetch and retry. 409 */
PERSON_RENAME_CONFLICT,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
@@ -100,14 +96,6 @@ public enum ErrorCode {
/** Internal inconsistency: expected training run row was not found after creation. 500 */
OCR_TRAINING_CONFLICT,
// --- Relationships (Stammbaum) ---
/** A relationship row with the given ID does not exist. 404 */
RELATIONSHIP_NOT_FOUND,
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */
TAG_NOT_FOUND,

View File

@@ -47,11 +47,6 @@ public class Person {
private Integer birthYear;
private Integer deathYear;
@Column(name = "family_member", nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean familyMember = false;
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
// Uses entity relationship rather than cross-domain repository access, avoiding a
// separate DB roundtrip while respecting domain boundaries.

View File

@@ -1,23 +0,0 @@
package org.raddatz.familienarchiv.model;
import java.util.UUID;
/**
* Published by PersonService when a save changes Person.getDisplayName() — i.e.
* any mutation to the fields that DisplayNameFormatter consumes (title,
* firstName, lastName). Listeners on the transcription side rewrite block text
* and sidecar entries that reference the old name.
*
* <p>This is the first custom application event in the codebase. The previous
* only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's
* built-in ApplicationReadyEvent. Future cross-domain decoupling should follow
* the same shape: record-typed event in model/, listener in the consuming
* domain's service/ package, synchronous @EventListener inside the publisher's
* transaction unless the workload genuinely needs to defer.
*/
public record PersonDisplayNameChangedEvent(
UUID personId,
String oldDisplayName,
String newDisplayName
) {
}

View File

@@ -1,30 +0,0 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PersonMention {
@NotNull
@Column(name = "person_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID personId;
@NotNull
@Size(max = 200)
@Column(name = "display_name", nullable = false, length = 200)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String displayName;
}

View File

@@ -7,8 +7,6 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@@ -35,14 +33,6 @@ public class TranscriptionBlock {
@Column(columnDefinition = "TEXT")
private String text;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "transcription_block_mentioned_persons",
joinColumns = @JoinColumn(name = "block_id"))
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
@Column(length = 200)
private String label;

View File

@@ -1,55 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.raddatz.familienarchiv.model.Person;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "person_relationships")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "notes")
public class PersonRelationship {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "person_id", nullable = false)
@JsonIgnore
private Person person;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "related_person_id", nullable = false)
@JsonIgnore
private Person relatedPerson;
@Enumerated(EnumType.STRING)
@Column(name = "relation_type", nullable = false, length = 30)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private RelationType relationType;
@Column(name = "from_year")
private Integer fromYear;
@Column(name = "to_year")
private Integer toYear;
@Column(length = 2000)
private String notes;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Instant createdAt;
}

View File

@@ -1,49 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Repository
public interface PersonRelationshipRepository extends JpaRepository<PersonRelationship, UUID> {
/**
* Bulk fetch for the network endpoint — pulls only edges of the given types.
* The service filters by family_member afterwards.
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.relationType IN :types")
List<PersonRelationship> findAllByRelationTypeIn(@Param("types") Collection<RelationType> types);
/** Used for the circular-PARENT_OF check in {@code addRelationship}. */
boolean existsByPersonIdAndRelatedPersonIdAndRelationType(
UUID personId, UUID relatedPersonId, RelationType relationType);
/**
* All edges incident on {@code personId} (either side) restricted to the given types.
* Used by the inference service to load a person's local subgraph for BFS.
*/
@Query("SELECT r FROM PersonRelationship r " +
"WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " +
"AND r.relationType IN :types")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
@Param("personId") UUID personId,
@Param("types") Collection<RelationType> types);
/**
* All edges incident on {@code personId} (either side), all types.
* Used by the "direct relationships" listings (person edit, side panel).
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.person.id = :personId OR r.relatedPerson.id = :personId")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
}

View File

@@ -1,24 +0,0 @@
package org.raddatz.familienarchiv.relationship;
/**
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
*
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
*/
public enum RelationToken {
UP,
DOWN,
SPOUSE,
SIBLING;
public RelationToken reverse() {
return switch (this) {
case UP -> DOWN;
case DOWN -> UP;
case SPOUSE -> SPOUSE;
case SIBLING -> SIBLING;
};
}
}

View File

@@ -1,20 +0,0 @@
package org.raddatz.familienarchiv.relationship;
/**
* Family-network relationship taxonomy.
*
* <p>Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once;
* the inference service walks them in both directions. {@link #PARENT_OF} is
* directional: A PARENT_OF B means A is the parent.
*/
public enum RelationType {
PARENT_OF,
SPOUSE_OF,
SIBLING_OF,
FRIEND,
COLLEAGUE,
EMPLOYER,
DOCTOR,
NEIGHBOR,
OTHER
}

View File

@@ -1,84 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
/**
* Stammbaum API. Endpoints split across two roots:
* <ul>
* <li>{@code /api/network} — the family graph</li>
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
* (PersonController is intentionally left untouched)</li>
* </ul>
*/
@RestController
@RequiredArgsConstructor
public class RelationshipController {
private final RelationshipService relationshipService;
// READ endpoints carry no @RequirePermission: all authenticated users may read the family graph.
// Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule.
@GetMapping("/api/network")
public NetworkDTO getNetwork() {
return relationshipService.getFamilyNetwork();
}
@GetMapping("/api/persons/{id}/relationships")
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
return relationshipService.getRelationships(id);
}
@GetMapping("/api/persons/{id}/inferred-relationships")
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
return relationshipService.getInferredRelationships(id);
}
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
return relationshipService.getRelationshipBetween(aId, bId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "No relationship path between " + aId + " and " + bId));
}
@PostMapping("/api/persons/{id}/relationships")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<RelationshipDTO> addRelationship(
@PathVariable UUID id,
@Valid @RequestBody CreateRelationshipRequest dto) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(relationshipService.addRelationship(id, dto));
}
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
relationshipService.deleteRelationship(id, relId);
}
@PatchMapping("/api/persons/{id}/family-member")
@RequirePermission(Permission.WRITE_ALL)
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
return relationshipService.setFamilyMember(id, dto.familyMember());
}
}

View File

@@ -1,211 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* Derives indirect family relationships by BFS over the family-graph subset
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
* row is required.
*/
@Service
@RequiredArgsConstructor
public class RelationshipInferenceService {
// 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins —
// the practical horizon for a 18991950 family archive. Paths longer than this are
// classified as LABEL_DISTANT and rarely carry meaningful relationship labels.
static final int MAX_DEPTH = 8;
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
static final String LABEL_DISTANT = "distant";
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private static Map<List<RelationToken>, String> buildLabelMap() {
Map<List<RelationToken>, String> m = new HashMap<>();
m.put(List.of(RelationToken.UP), "parent");
m.put(List.of(RelationToken.DOWN), "child");
m.put(List.of(RelationToken.SPOUSE), "spouse");
m.put(List.of(RelationToken.SIBLING), "sibling");
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
return Collections.unmodifiableMap(m);
}
/**
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
* assertions in unit tests.
*/
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
if (from.equals(to)) return Optional.empty();
Map<UUID, List<Edge>> adj = buildAdjacency();
return bfs(adj, from, to);
}
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
if (aToB.isEmpty()) return Optional.empty();
List<RelationToken> path = aToB.get();
return Optional.of(new InferredRelationshipDTO(
labelFor(path),
labelFor(reversePath(path)),
path.size()));
}
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
Map<UUID, List<Edge>> adj = buildAdjacency();
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
shortestPaths.remove(personId);
if (shortestPaths.isEmpty()) return List.of();
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
Map<UUID, Person> byId = new HashMap<>();
for (Person p : personService.getAllById(ids)) {
byId.put(p.getId(), p);
}
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
for (UUID id : ids) {
Person p = byId.get(id);
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
.thenComparing(d -> d.person().displayName()));
return out;
}
static String labelFor(List<RelationToken> path) {
String specific = LABEL_MAP.get(path);
return specific != null ? specific : LABEL_DISTANT;
}
private static List<RelationToken> reversePath(List<RelationToken> path) {
List<RelationToken> reversed = new ArrayList<>(path.size());
for (int i = path.size() - 1; i >= 0; i--) {
reversed.add(path.get(i).reverse());
}
return List.copyOf(reversed);
}
private Map<UUID, List<Edge>> buildAdjacency() {
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
Map<UUID, List<Edge>> adj = new HashMap<>();
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
for (PersonRelationship e : edges) {
UUID a = e.getPerson().getId();
UUID b = e.getRelatedPerson().getId();
switch (e.getRelationType()) {
case PARENT_OF -> {
addEdge(adj, a, b, RelationToken.DOWN);
addEdge(adj, b, a, RelationToken.UP);
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
}
case SPOUSE_OF -> {
addEdge(adj, a, b, RelationToken.SPOUSE);
addEdge(adj, b, a, RelationToken.SPOUSE);
}
case SIBLING_OF -> {
addEdge(adj, a, b, RelationToken.SIBLING);
addEdge(adj, b, a, RelationToken.SIBLING);
}
default -> { /* family graph excludes other types */ }
}
}
for (List<UUID> children : parentToChildren.values()) {
for (int i = 0; i < children.size(); i++) {
for (int j = i + 1; j < children.size(); j++) {
UUID c1 = children.get(i);
UUID c2 = children.get(j);
addEdge(adj, c1, c2, RelationToken.SIBLING);
addEdge(adj, c2, c1, RelationToken.SIBLING);
}
}
}
return adj;
}
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
}
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
if (e.target().equals(to)) return Optional.of(nextPath);
queue.add(e.target());
}
}
return Optional.empty();
}
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
queue.add(e.target());
}
}
return shortest;
}
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
out.addAll(prefix);
out.add(next);
return List.copyOf(out);
}
private record Edge(UUID target, RelationToken token) {}
}

View File

@@ -1,168 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* Owns the {@code person_relationships} table and the family_member flag.
* Always orchestrates {@link PersonService} for cross-domain access — never
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
*/
@Service
@RequiredArgsConstructor
public class RelationshipService {
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private final RelationshipInferenceService inferenceService;
public List<RelationshipDTO> getRelationships(UUID personId) {
personService.getById(personId);
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
return rels.stream().map(RelationshipService::toDTO).toList();
}
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
personService.getById(personId);
return inferenceService.findAllFor(personId);
}
public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
personService.getById(a);
personService.getById(b);
return inferenceService.infer(a, b);
}
public NetworkDTO getFamilyNetwork() {
// Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
List<Person> familyMembers = personService.findAllFamilyMembers();
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
}
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
List<RelationshipDTO> edges = new ArrayList<>();
for (PersonRelationship r : familyEdges) {
UUID p = r.getPerson().getId();
UUID rp = r.getRelatedPerson().getId();
if (familyIds.contains(p) && familyIds.contains(rp)) {
edges.add(toDTO(r));
}
}
return new NetworkDTO(nodes, edges);
}
@Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId());
validateYears(dto.fromYear(), dto.toYear());
if (dto.relationType() == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
}
PersonRelationship rel = PersonRelationship.builder()
.person(person)
.relatedPerson(relatedPerson)
.relationType(dto.relationType())
.fromYear(dto.fromYear())
.toYear(dto.toYear())
.notes(blankToNull(dto.notes()))
.build();
try {
// saveAndFlush so the unique_rel constraint violates synchronously and is
// caught here, not at commit time outside the @Transactional boundary.
return toDTO(relationshipRepository.saveAndFlush(rel));
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
}
}
@Transactional
public void deleteRelationship(UUID personId, UUID relId) {
PersonRelationship rel = relationshipRepository.findById(relId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
throw DomainException.forbidden(
"Relationship " + relId + " does not belong to person " + personId);
}
relationshipRepository.delete(rel);
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
return personService.setFamilyMember(personId, familyMember);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
}
}
private static RelationshipDTO toDTO(PersonRelationship r) {
Person p = r.getPerson();
Person rp = r.getRelatedPerson();
return new RelationshipDTO(
r.getId(),
p.getId(),
rp.getId(),
p.getDisplayName(),
p.getBirthYear(),
p.getDeathYear(),
rp.getDisplayName(),
rp.getBirthYear(),
rp.getDeathYear(),
r.getRelationType(),
r.getFromYear(),
r.getToYear(),
r.getNotes());
}
}

View File

@@ -1,15 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.relationship.RelationType;
import java.util.UUID;
public record CreateRelationshipRequest(
@NotNull UUID relatedPersonId,
@NotNull RelationType relationType,
Integer fromYear,
Integer toYear,
@Size(max = 2000) String notes
) {}

View File

@@ -1,4 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
public record FamilyMemberPatchDTO(boolean familyMember) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Pairwise inferred relationship for the document badge.
* {@code labelFromA} reads "Person B, from A's point of view" and vice-versa
* (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater").
*/
public record InferredRelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry
* is a derived relationship to another family member, labelled from the
* requesting person's perspective.
*/
public record InferredRelationshipWithPersonDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -1,11 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */
public record NetworkDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonNodeDTO> nodes,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */
public record PersonNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
Integer birthYear,
Integer deathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
) {}

View File

@@ -1,32 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.relationship.RelationType;
import java.util.UUID;
/**
* Wire shape for one stored relationship row. Both sides include name + years
* so the frontend can render the row from either perspective (e.g. on the
* subject's page the row reads "Elternteil von [related]"; on the object's
* page it reads "Kind von [person]").
*
* <p>Storage truth: {@code personId} is the {@code person_id} column,
* {@code relatedPersonId} is the {@code related_person_id} column. The
* frontend determines orientation by comparing against the viewpoint.
*/
public record RelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName,
Integer personBirthYear,
Integer personDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName,
Integer relatedPersonBirthYear,
Integer relatedPersonDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
Integer fromYear,
Integer toYear,
String notes
) {}

View File

@@ -26,9 +26,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
// Stammbaum-Knoten: alle Personen mit family_member = true.
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
@@ -41,7 +38,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -54,7 +50,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -63,7 +58,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)

View File

@@ -29,15 +29,6 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
@Query("""
SELECT DISTINCT b FROM TranscriptionBlock b
JOIN FETCH b.mentionedPersons
WHERE b.id IN (
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
)
""")
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
void deleteByAnnotationId(UUID annotationId);
int countByDocumentId(UUID documentId);

View File

@@ -1,77 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When
* Person.getDisplayName() flips during a rename, this listener rewrites every
* transcription block whose sidecar references the renamed person — both the
* literal "@OldName" inside block.text and the displayName carried in the
* {@link PersonMention} entries.
*
* <p>Synchronous on purpose: the rename and the propagation must commit as one
* transaction so a half-applied rewrite never reaches the archive. If the
* archive grows past tens of thousands of blocks, switch to
* {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation
* change.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PersonMentionPropagationListener {
private final TranscriptionBlockRepository blockRepository;
@EventListener
@Transactional // Joins publisher's transaction — async switch requires @TransactionalEventListener(AFTER_COMMIT)
public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) {
List<TranscriptionBlock> blocks =
blockRepository.findByPersonIdWithMentionsFetched(event.personId());
if (blocks.isEmpty()) {
return;
}
String oldNeedle = "@" + event.oldDisplayName();
String newNeedle = "@" + event.newDisplayName();
Pattern boundary = Pattern.compile(
Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))");
String replacement = Matcher.quoteReplacement(newNeedle);
for (TranscriptionBlock block : blocks) {
rewriteBlockText(block, boundary, replacement);
for (PersonMention mention : block.getMentionedPersons()) {
if (mention.getPersonId().equals(event.personId())) {
mention.setDisplayName(event.newDisplayName());
}
}
}
blockRepository.saveAllAndFlush(blocks);
log.info("Propagated rename {} → {} across {} block(s) for person {}",
event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId());
}
// Match @OldName only at a token boundary: not followed by a letter/digit/hyphen
// (catches @Hans-Peter when renaming Hans) AND not followed by " <Uppercase>"
// (catches @Hans Müller when renaming the single-name @Hans). False negatives —
// e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the
// conservative trade-off; the alternative (corruption) is irrecoverable.
private void rewriteBlockText(TranscriptionBlock block, Pattern boundary, String replacement) {
if (block.getText() != null) {
block.setText(boundary.matcher(block.getText()).replaceAll(replacement));
}
}
}

View File

@@ -13,14 +13,11 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,7 +31,6 @@ public class PersonService {
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
private final ApplicationEventPublisher eventPublisher;
public List<PersonSummaryDTO> findAll(String q) {
if (q == null) {
@@ -62,17 +58,6 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId);
person.setFamilyMember(familyMember);
return personRepository.save(person);
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
@@ -161,7 +146,6 @@ public class PersonService {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
String oldDisplayName = person.getDisplayName();
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
@@ -170,17 +154,7 @@ public class PersonService {
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
Person saved = personRepository.save(person);
String newDisplayName = saved.getDisplayName();
if (!Objects.equals(oldDisplayName, newDisplayName)) {
try {
eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName));
} catch (OptimisticLockingFailureException e) {
throw DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
"A referenced transcription block was modified concurrently — rename rolled back");
}
}
return saved;
return personRepository.save(person);
}
@Transactional

View File

@@ -1,30 +0,0 @@
-- Family network: marks a Person as a tree node and stores typed relationships
-- between two persons. The tree page (/stammbaum) only shows persons with
-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once;
-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the
-- reverse direction.
ALTER TABLE persons
ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE person_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
relation_type VARCHAR(30) NOT NULL,
from_year INTEGER,
to_year INTEGER,
notes VARCHAR(2000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_self_rel CHECK (person_id <> related_person_id),
CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type)
);
CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id);
CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id);
-- Symmetric SIBLING_OF: enforce only one row per unordered pair.
CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SIBLING_OF';

View File

@@ -1,6 +0,0 @@
-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the
-- SIBLING_OF index added in V54.
CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SPOUSE_OF';

View File

@@ -1,25 +0,0 @@
-- Sidecar table for @-mentions inside transcription_blocks.text.
-- Each row is one (block_id, person_id, display_name) tuple emitted by the
-- typeahead in the transcription editor. block.text contains the literal
-- "@DisplayName" — the UUID lives only here so historical text stays clean.
--
-- Schema choice: child table via @ElementCollection (mirrors the established
-- UserGroup.permissions / group_permissions pattern), NOT JSONB. The "show
-- all blocks mentioning person X" query on the person detail page joins on
-- the indexed person_id column — equally fast as JSONB GIN containment, no
-- new dependency. document_comments.comment_mentions stays as a many-to-many
-- to AppUser; the divergence is intentional: Person mentions need lazy
-- degradation when a person is deleted (no FK), while user mentions don't.
--
-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to
-- remain visible as plain unlinked text inside the transcription rather than
-- vanishing or cascade-deleting the block.
CREATE TABLE transcription_block_mentioned_persons (
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
person_id UUID NOT NULL,
display_name VARCHAR(200) NOT NULL
);
CREATE INDEX idx_tbmp_person_id ON transcription_block_mentioned_persons(person_id);
CREATE INDEX idx_tbmp_block_id ON transcription_block_mentioned_persons(block_id);

View File

@@ -1,5 +0,0 @@
-- Prevent duplicate sidecar rows for the same (block, person) pair.
-- @ElementCollection uses DELETE+INSERT per update so normal JPA writes can't
-- create duplicates, but a raw-SQL import or concurrent bypass of JPA could.
ALTER TABLE transcription_block_mentioned_persons
ADD CONSTRAINT uq_tbmp_block_person UNIQUE (block_id, person_id);

View File

@@ -57,13 +57,6 @@ class PersonControllerTest {
@Test
@WithMockUser
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
mockMvc.perform(get("/api/persons"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_returns200_withEmptyList() throws Exception {
when(personService.findAll(null)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons"))
@@ -71,7 +64,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser(authorities = "READ_ALL")
@WithMockUser
void getPersons_delegatesQueryParam_toService() throws Exception {
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
when(personService.findAll("Hans")).thenReturn(List.of(dto));
@@ -92,7 +85,6 @@ class PersonControllerTest {
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public String getNotes() { return null; }
public boolean isFamilyMember() { return false; }
public long getDocumentCount() { return 0; }
};
}
@@ -107,13 +99,6 @@ class PersonControllerTest {
@Test
@WithMockUser
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPerson_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
@@ -333,21 +318,6 @@ class PersonControllerTest {
.andExpect(jsonPath("$.lastName").value("Müller"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns409_whenRenameConflict() throws Exception {
UUID id = UUID.randomUUID();
when(personService.updatePerson(eq(id), any()))
.thenThrow(DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
"Concurrent block edit during rename"));
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Augusta\",\"lastName\":\"Raddatz\",\"personType\":\"PERSON\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("PERSON_RENAME_CONFLICT"));
}
// ─── POST /api/persons/{id}/merge ─────────────────────────────────────────
@Test

View File

@@ -183,36 +183,6 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
+ "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
@Test
@@ -251,34 +221,6 @@ class TranscriptionBlockControllerTest {
.andExpect(jsonPath("$.label").value("Anrede"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {

View File

@@ -1,160 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(RelationshipController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class RelationshipControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean RelationshipService relationshipService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final UUID PERSON_ID = UUID.randomUUID();
private static final UUID OTHER_ID = UUID.randomUUID();
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception {
when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty());
mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name()));
}
@Test
void getRelationships_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID))
.andExpect(status().isUnauthorized());
}
@Test
void getNetwork_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/network"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"familyMember\":true}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980,
"Bob Müller", 1930, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
mockMvc.perform(get("/api/network"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller"))
.andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
InferredRelationshipWithPersonDTO inferred =
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
when(relationshipService.getInferredRelationships(PERSON_ID))
.thenReturn(List.of(inferred));
mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].label").value("Großvater"))
.andExpect(jsonPath("$[0].hops").value(2));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
RelationshipDTO created = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", null, null,
"Bob Müller", null, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception {
UUID relId = UUID.randomUUID();
doNothing().when(relationshipService).deleteRelationship(any(), any());
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,353 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.service.PersonService;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
import static org.raddatz.familienarchiv.relationship.RelationType.*;
/**
* Felix Brandt — TDD red phase for RelationshipInferenceService.
* <p>
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
* a small graph through the mocked repository and asserts the exact abstract
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
* which asserts the fallback label, and {@code returns_empty_when_no_path}
* which asserts no result.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipInferenceServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@InjectMocks RelationshipInferenceService service;
// --- 1: parent ---
@Test
void parent_path_emits_UP() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(child.getId(), parent.getId()))
.hasValue(List.of(UP));
}
// --- 2: child ---
@Test
void child_path_emits_DOWN() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(parent.getId(), child.getId()))
.hasValue(List.of(DOWN));
}
// --- 3: spouse ---
@Test
void spouse_path_emits_SPOUSE() {
Person a = person();
Person b = person();
givenEdges(spouseOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SPOUSE));
}
// --- 4: sibling ---
@Test
void sibling_path_emits_SIBLING() {
Person a = person();
Person b = person();
givenEdges(siblingOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SIBLING));
}
// --- 5: grandparent (UP, UP) ---
@Test
void grandparent_path_emits_UP_UP() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
.hasValue(List.of(UP, UP));
}
// --- 6: grandchild (DOWN, DOWN) ---
@Test
void grandchild_path_emits_DOWN_DOWN() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
.hasValue(List.of(DOWN, DOWN));
}
// --- 7: great-grandparent (UP, UP, UP) ---
@Test
void great_grandparent_path_emits_UP_UP_UP() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(gc.getId(), g.getId()))
.hasValue(List.of(UP, UP, UP));
}
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
@Test
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(g.getId(), gc.getId()))
.hasValue(List.of(DOWN, DOWN, DOWN));
}
// --- 9: uncle/aunt (UP, SIBLING) ---
@Test
void uncle_aunt_path_emits_UP_SIBLING() {
Person grandparent = person();
Person parent = person();
Person uncle = person();
Person me = person();
// grandparent has two children: parent and uncle. me is parent's child.
givenEdges(
parentOf(grandparent, parent),
parentOf(grandparent, uncle),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
.hasValue(List.of(UP, SIBLING));
}
// --- 10: niece/nephew (SIBLING, DOWN) ---
@Test
void niece_nephew_path_emits_SIBLING_DOWN() {
Person grandparent = person();
Person uncle = person();
Person sibling = person();
Person niece = person();
// grandparent has uncle + sibling; sibling has niece.
givenEdges(
parentOf(grandparent, uncle),
parentOf(grandparent, sibling),
parentOf(sibling, niece));
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
.hasValue(List.of(SIBLING, DOWN));
}
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
@Test
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
Person ggp = person();
Person grandparent = person();
Person greatUncle = person();
Person parent = person();
Person me = person();
givenEdges(
parentOf(ggp, grandparent),
parentOf(ggp, greatUncle),
parentOf(grandparent, parent),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
.hasValue(List.of(UP, UP, SIBLING));
}
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
@Test
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
Person grandparent = person();
Person sibling = person();
Person greatUncle = person();
Person niece = person();
Person greatNiece = person();
givenEdges(
parentOf(grandparent, sibling),
parentOf(grandparent, greatUncle),
parentOf(sibling, niece),
parentOf(niece, greatNiece));
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
.hasValue(List.of(SIBLING, DOWN, DOWN));
}
// --- 13: parent-in-law (SPOUSE, UP) ---
@Test
void inlaw_parent_path_emits_SPOUSE_UP() {
Person inlaw = person();
Person spouse = person();
Person me = person();
givenEdges(
parentOf(inlaw, spouse),
spouseOf(me, spouse));
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
.hasValue(List.of(SPOUSE, UP));
}
// --- 14: child-in-law (DOWN, SPOUSE) ---
@Test
void inlaw_child_path_emits_DOWN_SPOUSE() {
Person me = person();
Person child = person();
Person inlawChild = person();
givenEdges(
parentOf(me, child),
spouseOf(child, inlawChild));
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
.hasValue(List.of(DOWN, SPOUSE));
}
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
@Test
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
Person me = person();
Person spouse = person();
Person spouseSibling = person();
givenEdges(
spouseOf(me, spouse),
siblingOf(spouse, spouseSibling));
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
.hasValue(List.of(SPOUSE, SIBLING));
}
// --- 16: cousin (UP, SIBLING, DOWN) ---
@Test
void cousin_1_path_emits_UP_SIBLING_DOWN() {
Person ggp = person();
Person parentMine = person();
Person uncle = person();
Person me = person();
Person cousin = person();
givenEdges(
parentOf(ggp, parentMine),
parentOf(ggp, uncle),
parentOf(parentMine, me),
parentOf(uncle, cousin));
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
.hasValue(List.of(UP, SIBLING, DOWN));
}
// --- 17: distant (label fallback for long chains) ---
@Test
void distant_label_for_long_chain() {
// Seven-generation ancestor: chain of seven PARENT_OF edges.
Person a0 = person();
Person a1 = person();
Person a2 = person();
Person a3 = person();
Person a4 = person();
Person a5 = person();
Person a6 = person();
Person a7 = person();
givenEdges(
parentOf(a0, a1),
parentOf(a1, a2),
parentOf(a2, a3),
parentOf(a3, a4),
parentOf(a4, a5),
parentOf(a5, a6),
parentOf(a6, a7));
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
assertThat(inferred).hasValueSatisfying(r -> {
assertThat(r.hops()).isEqualTo(7);
assertThat(r.labelFromA()).isEqualTo("distant");
assertThat(r.labelFromB()).isEqualTo("distant");
});
}
// --- 18: no path ---
@Test
void returns_empty_when_no_path() {
Person a = person();
Person b = person();
// No edges between them.
givenEdges(/* none */);
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
}
// --- 19: findAllFor delegates person resolution to PersonService ---
@Test
void findAllFor_resolves_persons_via_PersonService() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
when(personService.getAllById(anyList())).thenReturn(List.of(child));
List<InferredRelationshipWithPersonDTO> results = service.findAllFor(parent.getId());
assertThat(results).hasSize(1);
assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName());
}
// --- helpers ---
private void givenEdges(PersonRelationship... edges) {
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
}
private static Person person() {
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child) {
return edge(parent, child, PARENT_OF);
}
private static PersonRelationship spouseOf(Person a, Person b) {
return edge(a, b, SPOUSE_OF);
}
private static PersonRelationship siblingOf(Person a, Person b) {
return edge(a, b, SIBLING_OF);
}
private static PersonRelationship edge(Person a, Person b, RelationType type) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(type)
.createdAt(Instant.now())
.build();
}
}

View File

@@ -1,181 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Sara blocker 1 — service+DB integration over the family-network constraints.
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
* sibling index actually fire.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({
PostgresContainerConfig.class,
FlywayConfig.class,
RelationshipService.class,
RelationshipInferenceService.class,
PersonService.class
})
class RelationshipServiceIntegrationTest {
@Autowired RelationshipService relationshipService;
@Autowired PersonRepository personRepository;
@Autowired PersonRelationshipRepository relationshipRepository;
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
@Autowired PersonNameAliasRepository aliasRepository;
@Autowired EntityManager entityManager;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
relationshipRepository.deleteAll();
aliasRepository.deleteAll();
personRepository.deleteAll();
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
}
@Test
void addRelationship_stores_and_is_readable() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.getId());
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
assertThat(rels).hasSize(1);
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
}
@Test
void addRelationship_throws_409_when_duplicate() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
}
@Test
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
// Charlie is unrelated to this row.
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
// The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent();
}
@Test
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
relationshipService.deleteRelationship(bob.getId(), created.id());
assertThat(relationshipRepository.findById(created.id())).isEmpty();
}
@Test
void setFamilyMember_true_makes_person_appear_in_network() {
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
NetworkDTO before = relationshipService.getFamilyNetwork();
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
relationshipService.setFamilyMember(charlie.getId(), true);
NetworkDTO after = relationshipService.getFamilyNetwork();
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
assertThat(after.edges())
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
}
@Test
void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent();
// Detach managed entities so deleteById's cascade isn't fought by the
// persistence context (the rel row still references bob in memory).
entityManager.flush();
entityManager.clear();
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
personRepository.deleteById(bob.getId());
personRepository.flush();
assertThat(relationshipRepository.findById(relId)).isEmpty();
}
}

View File

@@ -1,209 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Felix Brandt — TDD red for RelationshipService domain rules.
*
* <p>Required by the plan (Nora blockers 1 + 2):
* <ul>
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
* </ul>
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
* and ownership permitted from either side.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@Mock RelationshipInferenceService inferenceService;
@InjectMocks RelationshipService service;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
alice = person("Alice");
bob = person("Bob");
charlie = person("Charlie");
}
// --- Nora blocker 1 ---
@Test
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
verify(relationshipRepository, never()).delete(any());
}
// --- Nora blocker 2 ---
@Test
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
when(personService.getById(bob.getId())).thenReturn(bob);
when(personService.getById(alice.getId())).thenReturn(alice);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_persists_with_storage_truth() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
PersonRelationship r = inv.getArgument(0);
r.setId(UUID.randomUUID());
r.setCreatedAt(Instant.now());
return r;
});
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
assertThat(result.fromYear()).isEqualTo(1900);
assertThat(result.notes()).isEqualTo("first born");
}
@Test
void deleteRelationship_succeeds_when_viewpoint_is_object() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
// Bob is the storage related_person; deleting from his viewpoint should work.
service.deleteRelationship(bob.getId(), relId);
verify(relationshipRepository).delete(rel);
}
@Test
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
UUID relId = UUID.randomUUID();
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
@Test
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
// Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
// Only the alice→bob edge must appear in the returned NetworkDTO.
UUID aliceBobRelId = UUID.randomUUID();
UUID aliceCharlieRelId = UUID.randomUUID();
PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);
when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));
NetworkDTO result = service.getFamilyNetwork();
assertThat(result.nodes()).hasSize(2);
assertThat(result.edges()).hasSize(1);
assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
}
// --- helpers ---
private static Person person(String name) {
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
return PersonRelationship.builder()
.id(id)
.person(parent)
.relatedPerson(child)
.relationType(RelationType.PARENT_OF)
.createdAt(Instant.now())
.build();
}
}

View File

@@ -1,127 +0,0 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TranscriptionBlockMentionsRepositoryTest {
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired DocumentRepository documentRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired EntityManager em;
private UUID documentId;
private UUID annotationId;
@BeforeEach
void setUp() {
Document doc = documentRepository.save(Document.builder()
.title("Letter")
.originalFilename("letter.pdf")
.status(DocumentStatus.UPLOADED)
.build());
documentId = doc.getId();
DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1")
.build());
annotationId = annotation.getId();
}
@Test
void mentionedPersons_roundTripsTwoEntries() {
UUID auguste = UUID.randomUUID();
UUID hermann = UUID.randomUUID();
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId)
.documentId(documentId)
.text("Liebe Tante @Auguste Raddatz, Onkel @Hermann Müller schreibt …")
.sortOrder(0)
.mentionedPersons(List.of(
new PersonMention(auguste, "Auguste Raddatz"),
new PersonMention(hermann, "Hermann Müller")
))
.build());
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(auguste, "Auguste Raddatz"),
org.assertj.core.groups.Tuple.tuple(hermann, "Hermann Müller"));
}
@Test
void mentionedPersons_defaultsToEmptyList_whenNotSet() {
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId)
.documentId(documentId)
.text("Plain text without mentions")
.sortOrder(0)
.build());
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getMentionedPersons()).isEmpty();
}
@Test
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
UUID augusteId = UUID.randomUUID();
UUID hermannId = UUID.randomUUID();
blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId).documentId(documentId)
.text("Brief von @Auguste Raddatz an @Hermann Müller.")
.sortOrder(0)
.mentionedPersons(List.of(
new PersonMention(augusteId, "Auguste Raddatz"),
new PersonMention(hermannId, "Hermann Müller")))
.build());
blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId).documentId(documentId)
.text("Unrelated block without Auguste.")
.sortOrder(1)
.mentionedPersons(List.of(new PersonMention(hermannId, "Hermann Müller")))
.build());
em.clear();
List<TranscriptionBlock> result =
blockRepository.findByPersonIdWithMentionsFetched(augusteId);
assertThat(result).hasSize(1);
assertThat(result.get(0).getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(augusteId, "Auguste Raddatz"),
org.assertj.core.groups.Tuple.tuple(hermannId, "Hermann Müller"));
}
}

View File

@@ -1,227 +0,0 @@
package org.raddatz.familienarchiv.service;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class PersonMentionPropagationListenerTest {
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired DocumentRepository documentRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired PersonRepository personRepository;
@Autowired EntityManager em;
private PersonMentionPropagationListener listener;
private UUID documentId;
private UUID annotationId;
@BeforeEach
void setUp() {
listener = new PersonMentionPropagationListener(blockRepository);
Document doc = documentRepository.save(Document.builder()
.title("Letter").originalFilename("letter.pdf")
.status(DocumentStatus.UPLOADED).build());
documentId = doc.getId();
DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
.documentId(documentId).pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1").build());
annotationId = annotation.getId();
}
private TranscriptionBlock saveBlock(String text, List<PersonMention> mentions) {
return blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId).documentId(documentId)
.text(text).sortOrder(0)
.mentionedPersons(mentions).build());
}
private UUID savedPersonId(String firstName, String lastName) {
Person p = personRepository.save(Person.builder()
.firstName(firstName).lastName(lastName).build());
return p.getId();
}
@Test
void rewritesTextAndSidecar_whenSingleBlockReferencesRenamedPerson() {
UUID personId = savedPersonId("Auguste", "Raddatz");
TranscriptionBlock saved = saveBlock(
"Liebe Tante @Auguste Raddatz, danke für den Brief.",
List.of(new PersonMention(personId, "Auguste Raddatz")));
em.clear();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
blockRepository.flush();
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getText()).isEqualTo("Liebe Tante @Augusta Raddatz, danke für den Brief.");
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getDisplayName)
.containsExactly("Augusta Raddatz");
}
@Test
void doesNotMatchPartialName_whenAnotherMentionShares_a_substring_with_renamed_person() {
UUID hansPeterId = savedPersonId("Hans-Peter", "Müller");
UUID hansId = savedPersonId("Hans", "Müller");
TranscriptionBlock saved = saveBlock(
"Heute hat @Hans-Peter Müller wieder mit @Hans Müller gesprochen.",
List.of(
new PersonMention(hansPeterId, "Hans-Peter Müller"),
new PersonMention(hansId, "Hans Müller")));
em.clear();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(hansId, "Hans Müller", "Hans Schmidt"));
blockRepository.flush();
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getText())
.isEqualTo("Heute hat @Hans-Peter Müller wieder mit @Hans Schmidt gesprochen.");
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(hansPeterId, "Hans-Peter Müller"),
org.assertj.core.groups.Tuple.tuple(hansId, "Hans Schmidt"));
}
@Test
void doesNotCorruptCompositeMention_whenRenamingSingleWordPerson() {
UUID hansMüllerId = savedPersonId("Hans", "Müller");
UUID hansId = savedPersonId(null, "Hans");
TranscriptionBlock saved = saveBlock(
"@Hans Müller schrieb. Auch @Hans hat geschrieben.",
List.of(
new PersonMention(hansMüllerId, "Hans Müller"),
new PersonMention(hansId, "Hans")));
em.clear();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(hansId, "Hans", "Henry"));
blockRepository.flush();
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getText())
.isEqualTo("@Hans Müller schrieb. Auch @Henry hat geschrieben.");
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(hansMüllerId, "Hans Müller"),
org.assertj.core.groups.Tuple.tuple(hansId, "Henry"));
}
@Test
void rewritesAllOccurrences_whenSameMentionAppearsTwiceInBlock() {
UUID personId = savedPersonId("Auguste", "Raddatz");
TranscriptionBlock saved = saveBlock(
"Heute hat @Auguste Raddatz geschrieben, dann hat @Auguste Raddatz nochmal geschrieben.",
List.of(new PersonMention(personId, "Auguste Raddatz")));
em.clear();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
blockRepository.flush();
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getText())
.isEqualTo("Heute hat @Augusta Raddatz geschrieben, dann hat @Augusta Raddatz nochmal geschrieben.");
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getDisplayName)
.containsExactly("Augusta Raddatz");
}
@Test
void propagatesAcross200Blocks_inUnderFiveSeconds_latencyFloor() {
UUID personId = savedPersonId("Auguste", "Raddatz");
List<UUID> blockIds = new ArrayList<>();
for (int i = 0; i < 200; i++) {
TranscriptionBlock saved = blockRepository.save(TranscriptionBlock.builder()
.annotationId(annotationId).documentId(documentId)
.text("Block " + i + " mentions @Auguste Raddatz here.")
.sortOrder(i)
.mentionedPersons(List.of(new PersonMention(personId, "Auguste Raddatz")))
.build());
blockIds.add(saved.getId());
}
blockRepository.flush();
em.clear();
long start = System.nanoTime();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
blockRepository.flush();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
assertThat(elapsedMs)
.as("Propagation across 200 blocks must stay under 5s — merge-blocking regression floor")
.isLessThan(5000L);
em.clear();
TranscriptionBlock first = blockRepository.findById(blockIds.get(0)).orElseThrow();
assertThat(first.getText()).contains("@Augusta Raddatz");
}
@Test
void doesNotThrow_whenBlockTextIsNull() {
UUID personId = savedPersonId("Auguste", "Raddatz");
saveBlock(null, List.of(new PersonMention(personId, "Auguste Raddatz")));
em.clear();
assertThatCode(() -> listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")))
.doesNotThrowAnyException();
}
@Test
void leavesUnrelatedBlockUntouched_whenNoSidecarReferencesPerson() {
UUID personId = savedPersonId("Auguste", "Raddatz");
TranscriptionBlock saved = saveBlock(
"Plain text without any mentions.",
List.of());
em.clear();
listener.onPersonDisplayNameChanged(
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
blockRepository.flush();
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getText()).isEqualTo("Plain text without any mentions.");
assertThat(reloaded.getMentionedPersons()).isEmpty();
}
}

View File

@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -10,22 +9,14 @@ import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -40,7 +31,6 @@ class PersonServiceTest {
@Mock PersonRepository personRepository;
@Mock PersonNameAliasRepository aliasRepository;
@Mock ApplicationEventPublisher eventPublisher;
@InjectMocks PersonService personService;
// ─── getById ─────────────────────────────────────────────────────────────
@@ -252,121 +242,6 @@ class PersonServiceTest {
assertThat(result.getAlias()).isEqualTo("Anna Alt");
}
// ─── updatePerson (display-name change event) ────────────────────────────
@Test
void updatePerson_publishesEvent_whenTitleChanges() {
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).title("Herr").firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).build();
String oldName = existing.getDisplayName();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setTitle("Frau"); dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
personService.updatePerson(id, dto);
ArgumentCaptor<PersonDisplayNameChangedEvent> captor =
ArgumentCaptor.forClass(PersonDisplayNameChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
PersonDisplayNameChangedEvent event = captor.getValue();
assertThat(event.personId()).isEqualTo(id);
assertThat(event.oldDisplayName()).isEqualTo(oldName);
assertThat(event.newDisplayName())
.isNotEqualTo(oldName)
.contains("Frau");
}
@Test
void updatePerson_doesNotPublishEvent_whenDisplayNameFieldsUnchanged() {
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).alias("old alias").build();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
dto.setAlias("new alias");
personService.updatePerson(id, dto);
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
}
@Test
void updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock() {
// Wire a real PersonMentionPropagationListener with a mocked block repository
// that throws on saveAllAndFlush. The publisher mock routes events to the
// listener so the catch path traverses the same call chain as production:
// PersonService → publishEvent → listener → saveAllAndFlush throws → catch.
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).build();
TranscriptionBlock referencingBlock = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(UUID.randomUUID()).annotationId(UUID.randomUUID())
.text("Brief von @Auguste Raddatz").sortOrder(0)
.mentionedPersons(new ArrayList<>(List.of(new PersonMention(id, "Auguste Raddatz"))))
.build();
TranscriptionBlockRepository blockRepo = mock(TranscriptionBlockRepository.class);
when(blockRepo.findByPersonIdWithMentionsFetched(id))
.thenReturn(List.of(referencingBlock));
when(blockRepo.saveAllAndFlush(any()))
.thenThrow(new ObjectOptimisticLockingFailureException(
TranscriptionBlock.class, referencingBlock.getId()));
PersonMentionPropagationListener realListener =
new PersonMentionPropagationListener(blockRepo);
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
doAnswer(inv -> {
realListener.onPersonDisplayNameChanged(inv.getArgument(0));
return null;
}).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setFirstName("Augusta"); dto.setLastName("Raddatz");
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.matches(e -> ((DomainException) e).getCode() == ErrorCode.PERSON_RENAME_CONFLICT)
.matches(e -> ((DomainException) e).getStatus().value() == 409);
}
@Test
void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() {
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).notes("first note").build();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
dto.setNotes("revised note");
personService.updatePerson(id, dto);
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
}
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test

View File

@@ -98,9 +98,7 @@ class TranscriptionServiceTest {
return b;
});
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
.text("hello").build();
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
@@ -170,7 +168,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
@@ -191,7 +189,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
@@ -210,7 +208,7 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
TranscriptionBlock result = transcriptionService.updateBlock(
docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new").build(), UUID.randomUUID());
docId, blockId, new UpdateTranscriptionBlockDTO("new", null), UUID.randomUUID());
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
}
@@ -228,7 +226,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
verify(senderModelService).checkAndTriggerTraining(senderId);
}
@@ -244,7 +242,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
verify(senderModelService, never()).checkAndTriggerTraining(any());
}
@@ -479,7 +477,7 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
@@ -504,7 +502,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("same text").build(), userId);
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null), userId);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}

View File

@@ -1,55 +0,0 @@
# ADR-006: Synchronous domain events inside the publisher's transaction
## Status
Accepted
## Context
Issue #362 introduced the first cross-domain side-effect in this codebase: when a Person's display name changes, every transcription block that mentions the person must be rewritten — both `block.text` (the literal `@OldName` substring) and the `mentionedPersons` sidecar (the `displayName` field on the matching `PersonMention`). The rewrite is bidirectionally referential — Person depends on Transcription to make the rename atomic, and Transcription depends on Person to know what the new display name is.
A direct method call from `PersonService` into `TranscriptionBlockService` would invert the existing dependency arrow (Document → Person, not Person → Transcription) and introduce a runtime-circular reference at the package level. Avoiding the cycle while keeping the rename atomic is the constraint this ADR addresses.
Two prior pieces of infrastructure constrain the solution:
- `transcription_blocks.version` (JPA `@Version`) — concurrent autosave on a referenced block must roll back the rename instead of silently overwriting the autosave.
- `OcrTrainingService.recoverOrphanedRuns` is the only existing `@EventListener` and it consumes Spring's built-in `ApplicationReadyEvent` — no precedent for a custom domain event in this codebase before now.
## Decision
`PersonService.updatePerson` publishes `PersonDisplayNameChangedEvent(personId, oldDisplayName, newDisplayName)` via `ApplicationEventPublisher` whenever `Person.getDisplayName()` flips between the pre-save snapshot and the post-save value. `PersonMentionPropagationListener` (in the transcription package's `service/` layer) handles the event with `@EventListener @Transactional`, finds blocks via `findByMentionedPersons_PersonId`, rewrites text + sidecar, and calls `saveAllAndFlush`.
**Synchronous on purpose.** Spring's default event dispatcher invokes listeners on the publishing thread, inside the publisher's transaction. The propagation runs as part of the same `@Transactional` boundary as the rename — `OptimisticLockingFailureException` from a referenced block bubbles back up, the surrounding transaction rolls back, and `PersonService.updatePerson` translates it to `DomainException(PERSON_RENAME_CONFLICT, 409)`.
**Pattern for future cross-domain decoupling:**
1. Event record in `model/` of the publishing domain (e.g. `PersonDisplayNameChangedEvent`).
2. Listener in `service/` of the consuming domain (e.g. `PersonMentionPropagationListener`).
3. `@EventListener @Transactional` on the listener method — no `@TransactionalEventListener` unless the work genuinely doesn't need to commit with the publisher.
4. `saveAllAndFlush` (not `saveAll`) on any write where exceptions must surface inside the listener call so the publisher can catch and translate them — `saveAll` defers exceptions to commit time, after the publisher's `try` block has exited.
5. Audit log line at `INFO` level on the listener method — historical-text mutation needs an audit trail.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| `PersonService` calls `TranscriptionBlockService.propagateDisplayNameChange(...)` directly | Inverts the dependency arrow. Person becomes runtime-coupled to Transcription; future domains that also care about renames (Comments, Notifications) compound the coupling. Events keep Person agnostic of who consumes them. |
| `@TransactionalEventListener(AFTER_COMMIT) + @Async` | The propagation would run after the rename commits, on a separate transaction. A failed propagation could leave block text out of sync with the renamed person until manual repair. Atomic transactional coupling is the safer default for historical-text mutation; switch to async only when the block count makes sync latency unacceptable (rough threshold: tens of thousands of blocks per renamed person). |
| Database trigger on `persons.last_name` | PL/pgSQL trigger would have to reach into `transcription_block_mentioned_persons` and `transcription_blocks.text`, smearing domain logic across SQL and Java. JPA's `@Version` would also be invisible to the trigger, so concurrent block autosaves would race silently. |
| Hibernate entity listener (`@PostUpdate` on Person) | Couples to Hibernate internals; harder to test in isolation; mixes lifecycle hooks with cross-domain side effects. Spring's `ApplicationEventPublisher` keeps the integration declarative and unit-testable. |
## Consequences
**Easier:**
- Person domain stays free of any compile-time dependency on Transcription. Future consumers (Comments, Notifications) subscribe to the same event without `PersonService` knowing they exist.
- Rename + propagation share one transaction → no half-applied state visible to readers, no orphaned rewrites if the rename fails after propagation, no "eventually-consistent" window for an archive that prizes historical fidelity.
- Concurrent autosaves on referenced blocks raise a structured 409 the frontend can render meaningfully (`error_person_rename_conflict`) instead of a generic 500.
- The pattern itself (record event in `model/`, listener in consumer's `service/`, sync `@EventListener @Transactional`, `saveAllAndFlush`) is reusable for the next cross-domain side effect.
**Harder:**
- Listener latency adds to the rename request's response time. The 200-block latency floor (< 2 s) is a merge-blocking regression test; if archive growth pushes it up, the migration path is one-annotation: switch to `@TransactionalEventListener(AFTER_COMMIT) + @Async` and add a manual-repair tool for propagation failures.
- Tests for the listener path require routing the publisher mock through a real listener (see `PersonServiceTest#updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock`). Slightly more setup than a pure-Mockito test, but exercises the production call chain.
- `saveAllAndFlush` is mandatory in any synchronous listener that must surface JPA exceptions to the publisher's `try`-block. `saveAll` alone defers the flush to transaction commit, which happens after the publisher returns.
## Future Direction
If a single rename starts touching tens of thousands of blocks, switch the listener to `@TransactionalEventListener(phase = AFTER_COMMIT)` paired with `@Async` and add (a) an idempotency key to the event so a retry doesn't double-rewrite, (b) an admin tool that scans for sidecar entries whose `displayName` doesn't match the current `Person.getDisplayName()` and repairs them. At that point the orphan-guard path (existsById check before the rewrite) re-enters the listener as a deliberate piece of the async machinery rather than dead code.

View File

@@ -1,987 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stammbaum — Document Badge · Inline Pill Variant · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
/* ── Masthead ── */
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:60px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:740px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.tag{background:#012851;color:#A1DCD8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tag.amber{background:#7c4a00;color:#fde68a}
/* ── Section headers ── */
.sh{margin:0 0 28px}
.sh h2{font-size:16px;font-weight:900;color:#012851;letter-spacing:-.2px}
.sh p{font-size:12.5px;color:#666;max-width:720px;line-height:1.7;margin-top:5px}
.section{margin-bottom:80px;padding-bottom:80px;border-bottom:2px dashed #C8C4BE}
.section:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
/* ── Token tables ── */
.token-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.token-table{border-radius:6px;overflow:hidden}
.token-table.light{background:#fff;border:1px solid #E0DDD6}
.token-table.dark{background:#0F1923;border:1px solid #1E2D3D}
.token-head{padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;border-bottom:1px solid #E0DDD6}
.token-table.light .token-head{background:#F4F2EC;color:#888;border-bottom-color:#E0DDD6}
.token-table.dark .token-head{background:#0A1218;color:#4E6070;border-bottom-color:#1E2D3D}
.token-table table{width:100%;border-collapse:collapse;font-size:11px}
.token-table.light td{padding:6px 14px;border-bottom:1px solid #F0EEE8;vertical-align:middle}
.token-table.dark td{padding:6px 14px;border-bottom:1px solid #1A2830;vertical-align:middle;color:#8AAABB}
.token-table tr:last-child td{border-bottom:none}
.token-table.light td:first-child{font-size:9px;font-weight:700;color:#888;width:160px}
.token-table.dark td:first-child{font-size:9px;font-weight:700;color:#4E6070;width:160px}
.swatch{display:inline-block;width:12px;height:12px;border-radius:2px;vertical-align:middle;margin-right:6px}
.swatch.bordered{border:1px solid #DDD}
.warn{display:inline-block;background:#FEF3C7;color:#92400E;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
.pass{display:inline-block;background:#D1FAE5;color:#065F46;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
/* ── Browser chrome ── */
.chrome{border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
.chrome.dark{background:#010e1e;border-color:#0d3358}
.chrome-bar{height:20px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0}
.chrome.dark .chrome-bar{background:#010a18;border-bottom-color:#0d3358}
.chrome-dot{width:6px;height:6px;border-radius:50%;background:#BDB8B1}
.chrome.dark .chrome-dot{background:#1a2a3a}
.chrome-url{flex:1;height:9px;background:#CCC8C2;border-radius:5px;margin-left:6px}
.chrome.dark .chrome-url{background:#1a2a3a}
/* ── App nav ── */
.app-nav{height:34px;background:#012851;border-top:4px solid #A1DCD8;display:flex;align-items:center;padding:0 12px;gap:10px;flex-shrink:0}
.app-logo{font-family:'Tinos',Georgia,serif;font-size:7px;font-weight:700;color:#fff;letter-spacing:.5px}
.app-link{font-size:5.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.4);white-space:nowrap}
.app-link.on{color:rgba(255,255,255,.9)}
.app-nav-r{margin-left:auto;display:flex;gap:6px;align-items:center}
.app-av{width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5)}
/* ── Sub-header bar ── */
.sub-header{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:6px;flex-shrink:0}
.chrome.dark .sub-header{background:#011526;border-bottom-color:#0d3358}
.back-btn{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
.chrome.dark .back-btn{color:#8b97a5}
.sh-divider{width:1px;height:18px;background:#E4E2D7;flex-shrink:0;margin:0 4px}
.chrome.dark .sh-divider{background:#0d3358}
.sh-doc-title{font-family:'Tinos',Georgia,serif;font-size:10px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.chrome.dark .sh-doc-title{color:#f0efe9}
/* person chips in sub-header */
.sh-persons{display:flex;align-items:center;gap:5px;flex-shrink:0}
.sh-chip{display:flex;align-items:center;gap:4px}
.sh-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
.sh-name{font-size:8px;font-weight:600;color:#4b5563;white-space:nowrap}
.chrome.dark .sh-name{color:#9ca3af}
.sh-arrow{color:#A1DCD8;flex-shrink:0}
.chrome.dark .sh-arrow{color:#00c7b1}
/* INLINE PILL */
.pill{display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;margin-left:5px;vertical-align:middle;line-height:1.5}
.chrome.dark .pill{background:rgba(0,199,177,.10);border-color:#00c7b1;color:#f0efe9}
/* sub-header actions */
.sh-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;margin-left:8px}
.sh-btn-ghost{height:22px;padding:0 7px;border:1.5px solid #E4E2D7;border-radius:3px;font-size:6.5px;font-weight:700;color:#4b5563;display:flex;align-items:center;gap:3px;flex-shrink:0}
.chrome.dark .sh-btn-ghost{border-color:#0d3358;color:#8b97a5}
.sh-btn-primary{height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;gap:3px;flex-shrink:0}
.chrome.dark .sh-btn-primary{background:#A1DCD8;color:#012851}
.sh-btn-icon{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
.chrome.dark .sh-btn-icon{border-color:#0d3358;color:#8b97a5}
/* ── Metadata drawer ── */
.meta-drawer{background:#ffffff;border-bottom:1px solid #E4E2D7;padding:14px 16px;flex-shrink:0}
.chrome.dark .meta-drawer{background:#011526;border-bottom-color:#0d3358}
.meta-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
.meta-col-head{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
.chrome.dark .meta-col-head{color:#8b97a5}
.meta-field{margin-bottom:8px}
.meta-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
.chrome.dark .meta-label{color:#8b97a5}
.meta-value{font-family:'Tinos',Georgia,serif;font-size:10px;color:#012851}
.chrome.dark .meta-value{color:#f0efe9}
/* ── Person card in metadata ── */
.person-card{display:flex;align-items:center;gap:5px;padding:3px 5px;border-radius:3px}
.p-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6.5px;font-weight:800;color:#fff;flex-shrink:0}
.p-name{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851}
.chrome.dark .p-name{color:#f0efe9}
/* ── PDF placeholder ── */
.pdf-area{background:#d4d0c8;flex:1;display:flex;align-items:center;justify-content:center;min-height:80px}
.chrome.dark .pdf-area{background:#010e1e}
.paper{background:#FFFEF8;width:40%;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:8px 10px;display:flex;flex-direction:column;gap:2px}
.chrome.dark .paper{background:#0d1820}
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px}
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px}
.chrome.dark .pl,.chrome.dark .ps{background:#1E2D3D}
/* ── Side-by-side layout ── */
.split-screens{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
.screen-lbl{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:8px;display:flex;align-items:center;gap:5px}
.lbl-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.cap{font-size:10px;color:#999;font-style:italic;line-height:1.6;margin-top:10px;max-width:460px}
/* ── Edge-case cards ── */
.edge-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:12px}
.edge-card{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
.edge-head{background:#F4F2EC;padding:8px 12px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;border-bottom:1px solid #E0DDD6}
.edge-body{padding:10px 12px}
.edge-note{font-size:10.5px;color:#555;line-height:1.65;margin-top:8px}
.no-badge{font-family:'Tinos',Georgia,serif;font-size:9px;color:#aaa;font-style:italic;padding:4px 5px}
/* ── Rules / implementation table ── */
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
.rules table{width:100%;border-collapse:collapse}
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
.rules td{font-size:11px;color:#444;padding:8px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.6}
.rules tr:last-child td{border-bottom:none}
.rules td:first-child{font-size:9px;font-weight:700;color:#012851;white-space:nowrap;width:200px}
.rules td code{font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px;color:#555;white-space:nowrap}
/* ── Pill anatomy callout ── */
.pill-anatomy{display:flex;align-items:center;gap:20px;background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:18px 24px;margin-bottom:16px;flex-wrap:wrap}
.pill-demo-light{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f9f8f4;border-radius:4px}
.pill-demo-dark{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#011526;border-radius:4px}
.pill-annotation{font-size:9.5px;color:#888;line-height:1.7}
.pill-annotation strong{color:#012851;font-weight:700}
/* ── Responsive preview containers ── */
.responsive-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
.responsive-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-bottom:16px}
/* ── Tablet sub-header ── */
.sub-header-tablet{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0}
.sh-title-truncated{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.sh-overflow-btn{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:9px;font-weight:700;flex-shrink:0}
.meta-stacked{padding:12px 14px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:9px}
.meta-stacked .meta-label{font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
.meta-stacked .meta-value{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851;margin-bottom:10px}
.meta-stacked .meta-section-head{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
/* ── Mobile sub-header ── */
.sub-header-mobile{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0}
.sh-title-mobile{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.meta-mobile{padding:10px 12px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:8.5px}
.meta-mobile .m-label{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:2px;margin-top:8px}
.meta-mobile .m-label:first-child{margin-top:0}
.meta-mobile .m-value{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;margin-bottom:2px}
.person-row-mobile{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}
.person-row-mobile .p-av-sm{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
.person-row-mobile .p-nm{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;white-space:nowrap}
</style>
</head>
<body>
<div class="doc">
<!-- ══ MASTHEAD ══════════════════════════════════════════════════════════════ -->
<div class="mh">
<h1>Stammbaum — Document Badge · Inline Pill Variant</h1>
<p>
Design spec for the inline relationship pill on the Document Detail page. Relationship labels appear
as <strong>inline pills directly next to each person's name</strong> — both in the 48 px sub-header bar
and in the Personen column of the 3-column metadata drawer. Example: Karl Raddatz
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">ELTERNTEIL</span>
→ Hans Raddatz
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">KIND</span>.
This is View 2 of 3 in the Stammbaum document-badge feature set.
</p>
<div class="byline">Familienarchiv · 2026-04-27 · Leonie Voss, UX Lead</div>
<div class="tag-row">
<span class="tag">Stammbaum Feature</span>
<span class="tag">View 2 of 3 — Document Badge</span>
<span class="tag">Inline Pill Variant</span>
<span class="tag">Desktop / Tablet / Mobile</span>
<span class="tag">Light + Dark</span>
</div>
</div>
<!-- ══ SECTION 1 — DESIGN TOKENS ════════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>1 · Design tokens</h2>
<p>All colour values used by the inline pill and its surrounding context. Light and dark themes are shown side by side. Contrast ratios are against the respective surface colour.</p>
</div>
<!-- Pill anatomy callout -->
<div class="pill-anatomy">
<div class="pill-demo-light">
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#012851;font-weight:700">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle">ELTERNTEIL</span>
</div>
<div class="pill-demo-dark">
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#f0efe9;font-weight:700">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle">ELTERNTEIL</span>
</div>
<div class="pill-annotation">
<strong>Pill anatomy</strong><br>
border-radius: 9999px &nbsp;·&nbsp; padding: 1px 8px<br>
font: Montserrat 9px 700 uppercase letter-spacing .07em<br>
margin-left: 8px from name span &nbsp;·&nbsp; vertical-align: middle
</div>
</div>
<div class="token-grid">
<!-- Light -->
<div class="token-table light">
<div class="token-head">Light theme — surface #ffffff</div>
<table>
<tr>
<td>Pill bg</td>
<td><span class="swatch bordered" style="background:rgba(161,220,216,.25)"></span>rgba(161,220,216,.25) — near-white on white<span class="pass">~14:1 AAA ✓ (text on near-white)</span></td>
</tr>
<tr>
<td>Pill border</td>
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — mint accent outline</td>
</tr>
<tr>
<td>Pill text</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — navy ink<span class="pass">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Person name</td>
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 — Montserrat 11px (sub-header)</td>
</tr>
<tr>
<td>Meta person name</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 9.5px (metadata drawer)</td>
</tr>
<tr>
<td>Sub-header bg</td>
<td><span class="swatch bordered" style="background:#ffffff"></span>#ffffff</td>
</tr>
<tr>
<td>Sub-header border</td>
<td><span class="swatch" style="background:#e4e2d7"></span>#e4e2d7</td>
</tr>
<tr>
<td>Arrow (decorative)</td>
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — <code>aria-hidden</code><span class="warn">non-text only</span></td>
</tr>
<tr>
<td>Meta label</td>
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — Montserrat 9px 700 uppercase<span class="pass">4.8:1 AA ✓</span></td>
</tr>
<tr>
<td>Meta value</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 13px<span class="pass">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Doc title</td>
<td>Tinos serif 18px · #012851</td>
</tr>
<tr>
<td>Avatar KR</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — navy</td>
</tr>
<tr>
<td>Avatar HR</td>
<td><span class="swatch" style="background:#5a2d6f"></span>#5a2d6f — purple</td>
</tr>
</table>
</div>
<!-- Dark -->
<div class="token-table dark">
<div class="token-head">Dark theme — surface #011526</div>
<table>
<tr>
<td>Pill bg</td>
<td><span class="swatch bordered" style="background:rgba(0,199,177,.10);border-color:#0d3358"></span>rgba(0,199,177,.10) — dark teal wash<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">passes AA ✓</span></td>
</tr>
<tr>
<td>Pill border</td>
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — turquoise</td>
</tr>
<tr>
<td>Pill text</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — warm white<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Person name</td>
<td><span class="swatch" style="background:#9ca3af"></span>#9ca3af — (sub-header)</td>
</tr>
<tr>
<td>Meta person name</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — (metadata drawer)</td>
</tr>
<tr>
<td>Sub-header bg</td>
<td><span class="swatch" style="background:#011526;border:1px solid #0d3358"></span>#011526</td>
</tr>
<tr>
<td>Sub-header border</td>
<td><span class="swatch" style="background:#0d3358"></span>#0d3358</td>
</tr>
<tr>
<td>Arrow (decorative)</td>
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — <code>aria-hidden</code><span class="warn" style="background:rgba(254,243,199,.1);color:#FDE68A;border:none">non-text only</span></td>
</tr>
<tr>
<td>Meta label</td>
<td><span class="swatch" style="background:#8b97a5"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">7.1:1 AAA ✓</span></td>
</tr>
<tr>
<td>Meta value</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Doc title</td>
<td>Tinos serif 18px · #f0efe9</td>
</tr>
</table>
</div>
</div>
<p style="font-size:10.5px;color:#888;font-style:italic;margin-top:6px">
⚠ Pill background rgba(161,220,216,.25) is nearly transparent on white — the effective contrast for the text is calculated against the near-white composite, yielding ~14:1.
The arrow between sender and receiver chips in the sub-header is <code>aria-hidden="true"</code> — directional meaning is conveyed by DOM order (sender before receiver) and the visual left-to-right reading order.
</p>
</div>
<!-- ══ SECTION 2 — DESKTOP LIGHT & DARK ═════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>2 · Desktop (1280 px) — light &amp; dark</h2>
<p>
Full document detail page at ~65% scale. Sub-header bar (48 px) shows inline pills next to avatar chips.
Metadata drawer is open, showing pills next to person names in the Personen column.
Both light and dark themes shown side by side.
</p>
</div>
<div class="split-screens">
<!-- ── LIGHT ── -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Light theme</div>
<div class="chrome">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<!-- App header -->
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Stammbaum</div>
<div class="app-link">Admin</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Sub-header -->
<div class="sub-header">
<div class="back-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="sh-divider"></div>
<div class="sh-doc-title">W-0311 · Divacca</div>
<div class="sh-persons">
<!-- Sender chip + pill -->
<div class="sh-chip">
<div class="sh-av" style="background:#012851">KR</div>
<span class="sh-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
<!-- Arrow -->
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
<!-- Receiver chip + pill -->
<div class="sh-chip">
<div class="sh-av" style="background:#5a2d6f">HR</div>
<span class="sh-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
<div class="sh-actions">
<div class="sh-btn-ghost">Details ▾</div>
<div class="sh-btn-ghost">Transkribieren</div>
<div class="sh-btn-primary">Bearbeiten</div>
<div class="sh-btn-icon">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</div>
</div>
</div>
<!-- Metadata drawer -->
<div class="meta-drawer">
<div class="meta-grid">
<!-- Col 1: Details -->
<div>
<div class="meta-col-head">Details</div>
<div class="meta-field">
<div class="meta-label">Datum</div>
<div class="meta-value"></div>
</div>
<div class="meta-field">
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
</div>
<div class="meta-field">
<div class="meta-label">Status</div>
<div class="meta-value">Hochgeladen</div>
</div>
</div>
<!-- Col 2: Personen with inline pills -->
<div>
<div class="meta-col-head">Personen</div>
<div class="meta-field">
<div class="meta-label">Absender</div>
<div class="person-card">
<div class="p-av" style="background:#012851">KR</div>
<span class="p-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label">Empfänger</div>
<div class="person-card">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span class="p-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
</div>
<!-- Col 3: Tags -->
<div>
<div class="meta-col-head">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
</div>
</div>
</div>
</div>
<!-- PDF area -->
<div class="pdf-area">
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
</div>
</div>
<p class="cap">Light. Pills appear in both the sub-header chip row and the metadata Personen column. Arrow between chips is mint-coloured and aria-hidden.</p>
</div>
<!-- ── DARK ── -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Dark theme</div>
<div class="chrome dark">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Stammbaum</div>
<div class="app-link">Admin</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div class="sub-header">
<div class="back-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="sh-divider"></div>
<div class="sh-doc-title">W-0311 · Divacca</div>
<div class="sh-persons">
<div class="sh-chip">
<div class="sh-av" style="background:#012851">KR</div>
<span class="sh-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
<div class="sh-chip">
<div class="sh-av" style="background:#5a2d6f">HR</div>
<span class="sh-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
<div class="sh-actions">
<div class="sh-btn-ghost">Details ▾</div>
<div class="sh-btn-ghost">Transkribieren</div>
<div class="sh-btn-primary">Bearbeiten</div>
<div class="sh-btn-icon">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</div>
</div>
</div>
<div class="meta-drawer">
<div class="meta-grid">
<div>
<div class="meta-col-head">Details</div>
<div class="meta-field">
<div class="meta-label">Datum</div>
<div class="meta-value"></div>
</div>
<div class="meta-field">
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
</div>
<div class="meta-field">
<div class="meta-label">Status</div>
<div class="meta-value">Hochgeladen</div>
</div>
</div>
<div>
<div class="meta-col-head">Personen</div>
<div class="meta-field">
<div class="meta-label">Absender</div>
<div class="person-card">
<div class="p-av" style="background:#012851">KR</div>
<span class="p-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label">Empfänger</div>
<div class="person-card">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span class="p-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
</div>
<div>
<div class="meta-col-head">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Berlin</span>
</div>
</div>
</div>
</div>
<div class="pdf-area">
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
</div>
</div>
<p class="cap">Dark. Pills flip to rgba(0,199,177,.10) bg, #00c7b1 border, #f0efe9 text. Sub-header and metadata surfaces both use #011526.</p>
</div>
</div>
</div>
<!-- ══ SECTION 3 — TABLET (768 px) ══════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>3 · Tablet (768 px)</h2>
<p>
The 3-column metadata grid collapses to a single stacked column. The sub-header truncates the document
title and moves secondary actions behind a "…" overflow button. Pills remain inline next to person names in both locations.
</p>
</div>
<div class="responsive-grid">
<!-- Tablet light -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Tablet · 768 px · Light</div>
<div class="chrome" style="max-width:400px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Tablet sub-header: back + title truncated + overflow -->
<div class="sub-header-tablet">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
<div class="sh-title-truncated">W-0311 · Divacca</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<div class="sh-btn-primary" style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center">Bearbeiten</div>
<div class="sh-overflow-btn">···</div>
</div>
</div>
<!-- Stacked metadata — Personen section with pills -->
<div class="meta-stacked">
<div class="meta-section-head">Personen</div>
<div style="margin-bottom:6px">
<div class="meta-label">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851;width:18px;height:18px;font-size:6px">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
</div>
</div>
<div style="margin-bottom:10px">
<div class="meta-label">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#5a2d6f;width:18px;height:18px;font-size:6px">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KIND</span>
</div>
</div>
<div class="meta-section-head">Details</div>
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
<div class="meta-label" style="margin-top:6px">Status</div>
<div class="meta-value">Hochgeladen</div>
<div class="meta-label" style="margin-top:6px">Schlagwörter</div>
<div style="display:flex;gap:4px;margin-top:3px">
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
</div>
</div>
<p class="cap">Tablet light. 3-column metadata collapses to single column. Pills stay inline with names. Sub-header shows only title + primary action + overflow menu.</p>
</div>
<!-- Tablet dark -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Tablet · 768 px · Dark</div>
<div class="chrome dark" style="max-width:400px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div class="sub-header-tablet" style="background:#011526;border-bottom:1px solid #0d3358">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center">Bearbeiten</div>
<div style="width:22px;height:22px;border:1.5px solid #0d3358;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#8b97a5;font-size:9px;font-weight:700">···</div>
</div>
</div>
<div style="padding:12px 14px;background:#011526;border-bottom:1px solid #0d3358;font-size:9px">
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Personen</div>
<div style="margin-bottom:6px">
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
</div>
</div>
<div style="margin-bottom:10px">
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">KIND</span>
</div>
</div>
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Details</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Ort</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Status</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
<div style="display:flex;gap:4px;margin-top:3px">
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
</div>
</div>
<p class="cap">Tablet dark. Same collapse behaviour. Dark pill tokens apply throughout.</p>
</div>
</div>
</div>
<!-- ══ SECTION 4 — MOBILE (375 px) ══════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>4 · Mobile (375 px)</h2>
<p>
Sub-header is simplified to back arrow and document title only — no person chips in the bar.
Metadata is full-width single column. Each person row is <code>flex; align-items: center; flex-wrap: nowrap</code>
— avatar, name, and pill on one line. If the name is very long the row wraps gracefully before the pill.
Only primary action buttons are shown.
</p>
</div>
<div class="responsive-grid">
<!-- Mobile light -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Mobile · 375 px · Light</div>
<div class="chrome" style="max-width:260px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Mobile sub-header: back + title only -->
<div class="sub-header-mobile">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
<div class="sh-title-mobile">W-0311 · Divacca</div>
<div style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
</div>
<!-- Mobile metadata: full-width stacked -->
<div class="meta-mobile">
<div class="m-label">Absender</div>
<div class="person-row-mobile">
<div class="p-av-sm" style="background:#012851">KR</div>
<span class="p-nm">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
</div>
<div class="m-label">Empfänger</div>
<div class="person-row-mobile">
<div class="p-av-sm" style="background:#5a2d6f">HR</div>
<span class="p-nm">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
</div>
<div class="m-label">Ort</div>
<div class="m-value">Divacca</div>
<div class="m-label">Status</div>
<div class="m-value">Hochgeladen</div>
<div class="m-label">Schlagwörter</div>
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
</div>
</div>
<p class="cap">Mobile light. No chips in sub-header — only title + primary action. Person rows: avatar + name + pill, flex-wrap:nowrap. Pill text drops to 6px to fit.</p>
</div>
<!-- Mobile dark -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Mobile · 375 px · Dark</div>
<div class="chrome dark" style="max-width:260px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div style="height:48px;background:#011526;border-bottom:1px solid #0d3358;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0">
<div style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5;flex-shrink:0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
</div>
<div style="padding:10px 12px;background:#011526;border-bottom:1px solid #0d3358;font-size:8.5px">
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:2px">
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px;margin-top:6px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:8px">
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Ort</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Status</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
</div>
</div>
<p class="cap">Mobile dark. Pill tokens #00c7b1/#f0efe9 at reduced 6px font — still passes AA on dark surface.</p>
</div>
</div>
</div>
<!-- ══ SECTION 5 — EDGE CASES ════════════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>5 · Edge cases — when no pill is rendered</h2>
<p>Three cases where the pill is silently omitted. The person name renders as normal — no gap, no placeholder.</p>
</div>
<div class="edge-grid">
<!-- Edge 1: no family relationship -->
<div class="edge-card">
<div class="edge-head">No family relationship → no pill</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
<!-- no pill -->
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#888">ME</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Maria Engel</span>
<!-- no pill -->
</div>
</div>
<div class="no-badge">— no pill —</div>
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship === null</code> because the backend returns 404 (no kinship path). Name renders without trailing pill.</div>
</div>
</div>
<!-- Edge 2: social relationship (Kollegen) → pill shows label -->
<div class="edge-card">
<div class="edge-head">Social relationship (Kollegen) → pill shows label</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#3d6b5a">FW</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Fritz Weber</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
</div>
</div>
<div class="edge-note">Non-family relationships (Kollege, Freund, etc.) returned by the inference endpoint still render as pills. The pill component is label-agnostic — it renders whatever <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship</code> provides.</div>
</div>
</div>
<!-- Edge 3: multiple receivers → no pill -->
<div class="edge-card">
<div class="edge-head">Multiple receivers → no pill</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Hans Raddatz</span>
</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#6a7a52">ER</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Elfriede Raddatz</span>
</div>
</div>
<div class="no-badge">— no pill —</div>
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">receivers.length &gt; 1</code> — inference endpoint is never called, <code>inferredRelationship</code> is <code>null</code>. No pill on any person chip.</div>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 6 — IMPLEMENTATION REFERENCE TABLE ═══════════════════════════ -->
<div class="section">
<div class="sh">
<h2>6 · Implementation reference</h2>
<p>Exact CSS/Tailwind values for every element of the pill and its context. Use these as the ground truth during implementation review.</p>
</div>
<div class="rules">
<table>
<thead>
<tr>
<th>Element</th>
<th>Tailwind / CSS</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Inline pill (light)</td>
<td><code>rounded-full border border-[#a1dcd8] bg-[rgba(161,220,216,.25)] px-2 py-0.5 text-[9px] font-bold uppercase tracking-[.07em] text-[#012851] ml-2 align-middle inline</code></td>
<td>Montserrat 9px 700. <code>ml-2</code> = 8px from name span. <code>vertical-align: middle</code> aligns cap-height to person name.</td>
</tr>
<tr>
<td>Inline pill (dark)</td>
<td><code>dark:bg-[rgba(0,199,177,.10)] dark:border-[#00c7b1] dark:text-[#f0efe9]</code></td>
<td>All three dark overrides applied together. Rest of pill class unchanged.</td>
</tr>
<tr>
<td>Person name span</td>
<td><code>font-sans text-[11px] text-[#4b5563] dark:text-[#9ca3af]</code> (sub-header) or <code>font-serif text-[9.5px] text-ink dark:text-[#f0efe9]</code> (metadata)</td>
<td>Name and pill share a <code>flex items-center gap-0</code> wrapper. Pill is the immediate next sibling of the name <code>&lt;span&gt;</code>.</td>
</tr>
<tr>
<td>Sub-header chip area</td>
<td><code>flex items-center gap-1.5</code></td>
<td>Wraps one sender chip + arrow + one receiver chip. Placed after the doc-title block, before action buttons.</td>
</tr>
<tr>
<td>Chip (avatar + name + pill)</td>
<td><code>flex items-center gap-1</code></td>
<td>Avatar, name span, and pill as three siblings inside the chip div.</td>
</tr>
<tr>
<td>Arrow between chips (sub-header)</td>
<td><code>h-2.5 w-2.5 shrink-0 text-[#a1dcd8] dark:text-[#00c7b1]</code> with <code>aria-hidden="true"</code></td>
<td>Arrow SVG carries no semantic information. DOM order (sender chip before receiver chip) conveys direction for assistive technology.</td>
</tr>
<tr>
<td>Person avatar (sub-header)</td>
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6px] font-bold text-white shrink-0</code></td>
<td>20×20 px. Initials in 6px bold white. Background colour is person-specific (set inline).</td>
</tr>
<tr>
<td>Person avatar (metadata)</td>
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6.5px] font-extrabold text-white shrink-0</code></td>
<td>Same 20×20 px. Slightly heavier weight (800) to match existing drawer card style.</td>
</tr>
<tr>
<td>Pill condition</td>
<td><code>{#if inferredRelationship} … {/if}</code> wraps both the sender pill and the receiver pill</td>
<td>Render only when <code>inferredRelationship !== null &amp;&amp; receivers.length === 1</code>. The check lives in <code>+page.server.ts</code>, not in the component.</td>
</tr>
<tr>
<td>Pill label value</td>
<td><code>inferredRelationship.labelFromA</code> next to sender, <code>inferredRelationship.labelFromB</code> next to receiver</td>
<td>Labels are pre-translated strings from the backend. No frontend i18n key needed for the label text itself.</td>
</tr>
<tr>
<td>Mobile person row</td>
<td><code>flex items-center gap-1 flex-nowrap</code></td>
<td><code>flex-wrap: nowrap</code> keeps avatar + name + pill on one line. If name overflows container, truncate name with <code>truncate</code>, never truncate the pill.</td>
</tr>
<tr>
<td>Mobile pill font-size</td>
<td><code>text-[6px]</code> at ≤375 px</td>
<td>Reduced from 9px (desktop) to 6px on mobile to fit without overflow. Contrast still passes AA at 6px bold.</td>
</tr>
<tr>
<td>Sub-header at mobile</td>
<td>Chips removed entirely from sub-header at <code>max-width: 767px</code></td>
<td>Sub-header shows only back arrow + document title + primary action button. Person chips with pills appear only in the metadata section on mobile.</td>
</tr>
</tbody>
</table>
</div>
<p style="font-size:10.5px;color:#888;margin-top:12px;line-height:1.7">
<strong style="color:#012851">Accessibility note:</strong> The pill text ("ELTERNTEIL", "KIND") is uppercase visually but the accessible name should be the mixed-case label from the backend (<code>labelFromA</code>). Apply <code>aria-label={labelFromA}</code> on the pill span so screen readers announce "Elternteil" not "E-L-T-E-R-N-T-E-I-L". The visual uppercase is achieved with CSS <code>text-transform: uppercase</code>, not by changing the source string.
</p>
</div>
</div><!-- /doc -->
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,197 +0,0 @@
# Frontend — Familienarchiv
## Overview
SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows.
## Tech Stack
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
- **Language**: TypeScript 5.9
- **Styling**: Tailwind CSS 4.1 + custom brand utilities
- **Build Tool**: Vite 7
- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static)
- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish
- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec)
- **PDF Rendering**: `pdfjs-dist` (PDF.js)
- **Testing**:
- Unit/Server: Vitest 4 (Node environment)
- Component: Vitest Browser Mode with Playwright (Chromium)
- E2E: Playwright (`frontend/e2e/`)
## Project Structure
```
src/
├── routes/ # SvelteKit file-based routing
│ ├── +layout.svelte # Global layout: header, nav, auth state
│ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── persons/ # Person directory, detail, edit, merge
│ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── chronik/ # Unified activity feed
│ ├── admin/ # User, group, tag, OCR, system management
│ ├── api/ # Internal API proxies (server-side only)
│ ├── login/ logout/ # Auth pages
│ └── ...
├── lib/
│ ├── components/ # Reusable Svelte components
│ │ ├── document/ # Document-specific components
│ │ ├── chronik/ # Activity feed components
│ │ └── user/ # User-related components
│ ├── generated/ # Auto-generated API types (openapi-typescript)
│ ├── server/ # Server-only utilities (db, auth helpers)
│ ├── services/ # Client-side service logic
│ ├── stores/ # Svelte stores (global state)
│ ├── types.ts # Shared TypeScript types
│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode)
│ ├── api.server.ts # Typed API client factory
│ ├── utils.ts # Shared utilities
│ ├── relativeTime.ts # Time formatting
│ ├── search.ts # Search utilities
│ └── paraglide/ # Generated i18n code
├── hooks/ # SvelteKit hooks (handle, handleFetch)
└── actions/ # Custom Svelte actions (click outside, etc.)
```
## API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`:
```typescript
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
- Use `result.data!` after an ok check
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
## Form Actions Pattern
```typescript
// +page.server.ts
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
// ...
return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success
}
};
```
## Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
```typescript
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date(doc.documentDate + 'T12:00:00')
);
```
## Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`):
| Class | Value | Usage |
| ------------ | --------- | -------------------------------- |
| `brand-navy` | `#002850` | Primary text, buttons, headers |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders |
Typography:
- `font-serif` (Merriweather) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
<!-- content -->
</div>
```
## Key UI Components
| Component | Props | Description |
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead |
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay |
| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block |
| `DocumentTopBar` | `document` | Responsive document metadata header |
## How to Run
### Development
```bash
cd frontend
npm install
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
```
### Build & Preview
```bash
npm run build # Production build
npm run preview # Preview production build
```
### Code Quality
```bash
npm run lint # Prettier + ESLint check
npm run format # Auto-fix formatting
npm run check # svelte-check (type checking)
```
### Testing
```bash
npm run test # Vitest unit + server tests (headless)
npm run test:coverage # Coverage report (server project only)
npm run test:e2e # Playwright E2E tests
npm run test:e2e:headed # Playwright E2E with visible browser
npm run test:e2e:ui # Playwright UI mode
```
### Regenerate API Types
Requires backend running with `--spring.profiles.active=dev`:
```bash
npm run generate:api
```
## Vite Proxy
During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`).
## i18n (Paraglide)
Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with:
```bash
npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
```
Or let the Vite plugin handle it automatically during dev/build.

View File

@@ -1,141 +0,0 @@
# E2E Tests — Familienarchiv
## Overview
End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO).
## Tech Stack
- **Test Runner**: Playwright (`@playwright/test`)
- **Browser**: Chromium (desktop)
- **Locale**: `de-DE` (ensures German language detection)
- **Auth**: Shared session cookie stored after setup
## Project Structure
```
frontend/e2e/
├── auth.setup.ts # Authentication setup — logs in and saves session
├── auth.spec.ts # Authentication flows (login, logout, register)
├── admin.spec.ts # Admin panel CRUD operations
├── annotations.spec.ts # Document annotation features
├── bottom-panel.spec.ts # Bottom panel / transcription panel
├── dashboard-*.spec.ts # Dashboard variants and screenshots
├── documents.spec.ts # Document upload, edit, search
├── focus-rings.spec.ts # Accessibility focus ring tests
├── header.spec.ts # Navigation header
├── history.spec.ts # Chronik / activity feed
├── korrespondenz.spec.ts # Correspondence timeline
├── lang.spec.ts # Language switching
├── password-reset.spec.ts # Password reset flow
├── permissions.spec.ts # Role-based access control
├── persons.spec.ts # Person directory CRUD
├── profile.spec.ts # User profile
├── theme.spec.ts # Dark/light mode
├── transcription.spec.ts # Transcription workflows
├── accessibility.spec.ts # Axe accessibility scans
├── fixtures/ # Test data fixtures
└── helpers/ # Test helper utilities
```
## Authentication Strategy
Tests share auth state via a stored session cookie:
1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json`
2. **Tests**: All test projects depend on `setup` and reuse the stored session
This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`).
## Configuration
Config lives in `frontend/playwright.config.ts`:
| Setting | Value | Notes |
| --------------- | ----------------------- | ------------------------------ |
| `testDir` | `./e2e` | Test file location |
| `fullyParallel` | `false` | Shared auth state |
| `workers` | `1` | Sequential execution |
| `screenshot` | `'on'` | Always capture |
| `video` | `'retain-on-failure'` | Keep on failure |
| `trace` | `'retain-on-failure'` | Keep on failure |
| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` |
The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL.
## How to Run
### Prerequisites
The full stack must be running (or the `webServer` config will start the frontend dev server):
```bash
# Start infrastructure
docker-compose up -d
# Ensure backend is healthy
curl http://localhost:8080/actuator/health
```
### Run E2E Tests
```bash
cd frontend
# Headless (CI mode)
npm run test:e2e
# With visible browser
npm run test:e2e:headed
# Interactive UI mode
npm run test:e2e:ui
# Run a specific test file
npx playwright test documents.spec.ts
# Run with a different base URL (e.g., docker frontend on 5173)
E2E_BASE_URL=http://localhost:5173 npx playwright test
```
## Writing New E2E Tests
1. Create a new `.spec.ts` file in `frontend/e2e/`
2. Use the shared auth state (no manual login needed)
3. Use page object patterns or helper functions from `helpers/`
4. Add `test-data-id` attributes to components for stable selectors
5. Run with `--debug` or `--ui` to troubleshoot
### Example Test Pattern
```typescript
import { test, expect } from '@playwright/test';
test('user can create a document', async ({ page }) => {
await page.goto('/documents/new');
await page.getByTestId('document-title').fill('Test Document');
await page.getByTestId('save-button').click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
});
```
## Accessibility Testing
`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test.
```bash
npx playwright test accessibility.spec.ts
```
## Troubleshooting
| Issue | Solution |
| --------------------- | ---------------------------------------- |
| Auth failures | Delete `e2e/.auth/user.json` and re-run |
| Backend not reachable | Ensure `docker-compose up -d` is running |
| Flaky tests | Increase timeout or add explicit waits |
| Screenshots missing | Check `test-results/e2e/` |
## CI Integration
E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first.

View File

@@ -1,154 +0,0 @@
import { test, expect, devices } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
/**
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
*
* Strategy:
* - Create a document, a Person, and a transcription block whose text contains
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
* - Open the document in read mode.
* - B20: page.hover() on the .person-mention link → hover card mounts.
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
* to /persons/{id} without ever showing the hover card.
*/
let docId: string;
let personId: string;
let docHref: string;
test.describe.configure({ mode: 'serial' });
test.describe('Person mention — read mode', () => {
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// 1. Person we will mention.
const personRes = await request.post('/api/persons', {
data: {
firstName: 'Auguste',
lastName: 'Raddatz',
personType: 'PERSON',
birthYear: 1882,
deathYear: 1944
}
});
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
const person = await personRes.json();
personId = person.id;
// 2. Document with a PDF so the transcription panel is mountable.
const docRes = await request.post('/api/documents', {
multipart: { title: 'E2E Person Mention Read', documentDate: '1945-05-08' }
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
const doc = await docRes.json();
docId = doc.id;
docHref = `${baseURL}/documents/${docId}`;
await request.put(`/api/documents/${docId}`, {
multipart: {
title: doc.title,
documentDate: '1945-05-08',
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
// 3. Annotation to anchor the block on the page.
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
});
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
data: {
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.1,
text: 'Brief an @Auguste Raddatz vom Mai 1944',
label: null,
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
}
});
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
});
test.afterAll(async ({ request }) => {
if (docId) await request.delete(`/api/documents/${docId}`);
if (personId) await request.delete(`/api/persons/${personId}`);
});
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
await page.goto(docHref);
await page.getByRole('button', { name: 'Transkription' }).click();
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await expect(link).toBeVisible({ timeout: 5000 });
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
// The @ trigger is stripped from the rendered text per spec
await expect(link).toHaveText('Auguste Raddatz');
});
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
await page.goto(docHref);
await page.getByRole('button', { name: 'Transkription' }).click();
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await link.hover();
const card = page.getByTestId('person-hover-card');
await expect(card).toBeVisible({ timeout: 5000 });
// Loaded state: person displayName is rendered inside the card
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
// Footer link points to /persons/{id}
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
});
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
await page.goto(docHref);
await page.getByRole('button', { name: 'Transkription' }).click();
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await link.hover();
await expect(page.getByTestId('person-hover-card')).toBeVisible();
// Move pointer away
await page.mouse.move(0, 0);
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
});
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
const context = await browser.newContext({
...devices['Pixel 7'],
storageState: STORAGE_STATE
});
const touchPage = await context.newPage();
try {
await touchPage.goto(docHref);
await touchPage.getByRole('button', { name: 'Transkription' }).click();
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await expect(link).toBeVisible({ timeout: 5000 });
await link.tap();
// The card never mounted — the tap navigated directly per spec
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
} finally {
await context.close();
}
});
});

View File

@@ -1,202 +0,0 @@
/**
* E2E regression tests for PersonTypeahead dropdown visibility.
*
* These tests verify that the dropdown list is never clipped by a parent
* container's stacking context — the root cause of issue #343.
*
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
* as required by the acceptance criteria.
*/
import { test, expect, type Page } from '@playwright/test';
/**
* Find a document edit URL to use as the test page.
* Falls back to /documents/new if no existing document is found.
*/
async function getDocumentEditUrl(page: Page): Promise<string> {
await page.goto('/');
await page.waitForLoadState('networkidle');
const firstDocLink = page.locator('a[href^="/documents/"]').first();
const href = await firstDocLink.getAttribute('href').catch(() => null);
if (href) {
return `${href}/edit`;
}
return '/documents/new';
}
/** Wait for the listbox to become visible after triggering a search. */
async function waitForListbox(page: Page): Promise<void> {
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
}
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
// Find the sender typeahead input (the visible text input, not the hidden one)
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
// Type to trigger the dropdown
await senderInput.click();
await senderInput.fill('a');
// Wait for the dropdown to appear (handles debounce automatically)
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
// Verify the bounding box is within the viewport (not clipped)
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(720);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
});
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
page
}) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
const inputBox = await senderInput.boundingBox();
expect(inputBox).not.toBeNull();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const dropdownBox = await dropdown.boundingBox();
expect(dropdownBox).not.toBeNull();
// Dropdown must appear below the input, not on top or clipped behind it
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
});
});
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(1024);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
});
});
test.describe('PersonTypeahead — keyboard navigation', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('ArrowDown moves focus to the first option', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
await senderInput.press('ArrowDown');
// First option should now be the active descendant
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
expect(activeDescendant).toBeTruthy();
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
});
test('Escape key closes the dropdown', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
await senderInput.press('Escape');
await expect(dropdown).not.toBeVisible();
});
test('aria-expanded is true when dropdown is open', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
// Initially closed
const initialExpanded = await senderInput.getAttribute('aria-expanded');
expect(initialExpanded).toBe('false');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const expanded = await senderInput.getAttribute('aria-expanded');
expect(expanded).toBe('true');
});
});
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
// Click somewhere else on the page
await page.click('body', { position: { x: 10, y: 10 } });
await expect(dropdown).not.toBeVisible();
});
});

View File

@@ -1,60 +0,0 @@
import { test, expect } from '@playwright/test';
// Tests skipped until Playwright Chromium is installed in CI — see issue #363.
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();
});
test('/stammbaum either shows an empty state or at least one node', async ({ page }) => {
// Plan journey 3 (empty branch) and journey 1 (populated branch) covered jointly:
// the test passes whenever the page renders one of the two coherent states.
await page.goto('/stammbaum');
const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
const anyNode = page.locator('svg[role="img"][aria-label="Stammbaum"] g[role="button"]');
await expect(async () => {
const emptyVisible = await empty.isVisible().catch(() => false);
const nodeCount = await anyNode.count();
expect(emptyVisible || nodeCount > 0).toBe(true);
}).toPass();
if (await empty.isVisible().catch(() => false)) {
await expect(page.getByRole('link', { name: /Zur Personenliste/ })).toBeVisible();
}
});
test('person edit Stammbaum card surfaces the year-range error', async ({ page }) => {
// Plan task 36: Bis < Von triggers the inline error and keeps the form unsubmitted.
// We pick the first person, open the edit page, expand the add-rel form, and
// inspect the validation message bound to the Bis field.
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await expect(page).toHaveURL(/\/persons\/[^/]+/);
await page.goto(page.url() + '/edit');
// Open the add-rel form
const addBtn = page.getByRole('button', { name: /Beziehung hinzufügen/i });
await addBtn.click();
// Enter Von 1935, Bis 1920 → expect the year-range error
const fromInput = page.locator('input[name="fromYear"]');
const toInput = page.locator('input[name="toYear"]');
await fromInput.fill('1935');
await toInput.fill('1920');
await expect(page.locator('#add-rel-year-error')).toBeVisible();
await expect(page.locator('#add-rel-year-error')).toContainText(/Bis.*Von|nicht vor/i);
});
});

View File

@@ -420,12 +420,6 @@
"notification_unread": "ungelesen",
"mention_btn_label": "Person erwähnen",
"mention_popup_empty": "Keine Nutzer gefunden",
"person_mention_open_link": "Zur Person",
"person_mention_hover_hint": "Klick öffnet Seite",
"person_mention_load_error": "Person konnte nicht geladen werden.",
"person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen",
"page_title_home": "Archiv",
"page_title_persons": "Personen",
"page_title_admin": "Administration",
@@ -548,7 +542,6 @@
"person_alias_btn_delete": "Entfernen",
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
"error_person_rename_conflict": "Eine andere Bearbeitung hat einen verknüpften Transkriptionsblock gleichzeitig geändert. Bitte erneut versuchen.",
"validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
@@ -914,80 +907,5 @@
"bulk_edit_loading": "Dokumente werden geladen…",
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
"bulk_edit_topbar_title": "Massenbearbeitung",
"bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum",
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
"relation_parent_of": "Elternteil von",
"relation_child_of": "Kind von",
"relation_spouse_of": "Ehegatte",
"relation_sibling_of": "Geschwister",
"relation_friend": "Freund",
"relation_colleague": "Kollege",
"relation_employer": "Arbeitgeber",
"relation_doctor": "Arzt",
"relation_neighbor": "Nachbar",
"relation_other": "Sonstige",
"relation_inferred_parent": "Elternteil",
"relation_inferred_child": "Kind",
"relation_inferred_spouse": "Ehegatte",
"relation_inferred_sibling": "Geschwister",
"relation_inferred_grandparent": "Großelternteil",
"relation_inferred_grandchild": "Enkelkind",
"relation_inferred_great_grandparent": "Urgroßelternteil",
"relation_inferred_great_grandchild": "Urenkel",
"relation_inferred_uncle_aunt": "Onkel/Tante",
"relation_inferred_niece_nephew": "Nichte/Neffe",
"relation_inferred_great_uncle_aunt": "Großonkel/Großtante",
"relation_inferred_great_niece_nephew": "Großnichte/Großneffe",
"relation_inferred_inlaw_parent": "Schwiegerelternteil",
"relation_inferred_inlaw_child": "Schwiegerkind",
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
"relation_inferred_cousin_1": "Cousin/Cousine",
"relation_inferred_distant": "Weitläufige Verwandtschaft",
"doc_details_field_relationship": "Verwandtschaft",
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
"stammbaum_empty_link": "→ Zur Personenliste",
"stammbaum_panel_direct_rels": "Direkte Beziehungen",
"stammbaum_panel_derived_rels": "Abgeleitete Beziehungen",
"stammbaum_panel_to_person": "Zur Personenseite →",
"stammbaum_panel_add_rel": "+ Beziehung hinzufügen",
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
"stammbaum_zoom_in": "Vergrößern",
"stammbaum_zoom_out": "Verkleinern",
"stammbaum_generations": "Generationen",
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
"relation_year_from": "ab {year}",
"relation_year_to": "bis {year}",
"relation_year_error_bis_before_von": "Bis-Jahr darf nicht vor Von-Jahr liegen.",
"relation_label_family_member": "Als Familienmitglied",
"relation_toggle_add_to_tree": "Zum Stammbaum hinzufügen",
"relation_toggle_remove_from_tree": "Aus Stammbaum entfernen",
"relation_label_in_tree": "Erscheint im Stammbaum",
"relation_label_view_in_tree": "Ansehen →",
"relation_label_direct": "Direkte Beziehungen",
"relation_label_derived": "Abgeleitete Beziehungen",
"relation_btn_add": "Hinzufügen",
"relation_btn_save": "Speichern",
"relation_btn_cancel": "Abbrechen",
"relation_form_group_family": "Familie",
"relation_form_group_social": "Sozial",
"relation_form_field_type": "Typ",
"relation_form_field_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920",
"person_relationships_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt."
"bulk_edit_count_pill": "{count} werden bearbeitet"
}

View File

@@ -420,12 +420,6 @@
"notification_unread": "unread",
"mention_btn_label": "Mention person",
"mention_popup_empty": "No users found",
"person_mention_open_link": "Open person",
"person_mention_hover_hint": "Click opens the page",
"person_mention_load_error": "Could not load person.",
"person_mention_popup_empty": "No persons found",
"person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person",
"page_title_home": "Archive",
"page_title_persons": "Persons",
"page_title_admin": "Administration",
@@ -548,7 +542,6 @@
"person_alias_btn_delete": "Remove",
"error_alias_not_found": "The name alias was not found.",
"error_invalid_person_type": "The specified person type is not valid.",
"error_person_rename_conflict": "Another edit modified a referenced transcription block at the same time. Please try again.",
"validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.",
@@ -914,80 +907,5 @@
"bulk_edit_loading": "Loading documents…",
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
"bulk_edit_topbar_title": "Bulk edit",
"bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree",
"error_relationship_not_found": "Relationship not found.",
"error_circular_relationship": "This relationship would form a cycle.",
"error_duplicate_relationship": "This relationship already exists.",
"relation_parent_of": "Parent of",
"relation_child_of": "Child of",
"relation_spouse_of": "Spouse",
"relation_sibling_of": "Sibling",
"relation_friend": "Friend",
"relation_colleague": "Colleague",
"relation_employer": "Employer",
"relation_doctor": "Doctor",
"relation_neighbor": "Neighbour",
"relation_other": "Other",
"relation_inferred_parent": "Parent",
"relation_inferred_child": "Child",
"relation_inferred_spouse": "Spouse",
"relation_inferred_sibling": "Sibling",
"relation_inferred_grandparent": "Grandparent",
"relation_inferred_grandchild": "Grandchild",
"relation_inferred_great_grandparent": "Great-grandparent",
"relation_inferred_great_grandchild": "Great-grandchild",
"relation_inferred_uncle_aunt": "Uncle/Aunt",
"relation_inferred_niece_nephew": "Niece/Nephew",
"relation_inferred_great_uncle_aunt": "Great-uncle/Aunt",
"relation_inferred_great_niece_nephew": "Great-niece/Nephew",
"relation_inferred_inlaw_parent": "Parent-in-law",
"relation_inferred_inlaw_child": "Child-in-law",
"relation_inferred_sibling_inlaw": "Sibling-in-law",
"relation_inferred_cousin_1": "Cousin",
"relation_inferred_distant": "Distant relative",
"doc_details_field_relationship": "Relationship",
"stammbaum_empty_heading": "No family members yet",
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
"stammbaum_empty_link": "→ Go to person list",
"stammbaum_panel_direct_rels": "Direct relationships",
"stammbaum_panel_derived_rels": "Derived relationships",
"stammbaum_panel_to_person": "Go to person page →",
"stammbaum_panel_add_rel": "+ Add relationship",
"stammbaum_relationships_heading": "Family tree & relationships",
"stammbaum_zoom_in": "Zoom in",
"stammbaum_zoom_out": "Zoom out",
"stammbaum_generations": "Generations",
"relation_error_duplicate": "This relationship already exists.",
"relation_error_circular": "This relationship would form a cycle.",
"relation_error_self": "A person cannot be related to themselves.",
"relation_year_from": "from {year}",
"relation_year_to": "until {year}",
"relation_year_error_bis_before_von": "End year must not precede start year.",
"relation_label_family_member": "Family member",
"relation_toggle_add_to_tree": "Add to family tree",
"relation_toggle_remove_from_tree": "Remove from family tree",
"relation_label_in_tree": "Appears in the family tree",
"relation_label_view_in_tree": "View →",
"relation_label_direct": "Direct relationships",
"relation_label_derived": "Derived relationships",
"relation_btn_add": "Add",
"relation_btn_save": "Save",
"relation_btn_cancel": "Cancel",
"relation_form_group_family": "Family",
"relation_form_group_social": "Social",
"relation_form_field_type": "Type",
"relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920",
"person_relationships_heading": "Relationships",
"person_relationships_empty": "No relationships known yet."
"bulk_edit_count_pill": "{count} will be edited"
}

View File

@@ -420,12 +420,6 @@
"notification_unread": "no leído",
"mention_btn_label": "Mencionar persona",
"mention_popup_empty": "No se encontraron usuarios",
"person_mention_open_link": "Ir a la persona",
"person_mention_hover_hint": "Clic abre la página",
"person_mention_load_error": "No se pudo cargar la persona.",
"person_mention_popup_empty": "No se encontraron personas",
"person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona",
"page_title_home": "Archivo",
"page_title_persons": "Personas",
"page_title_admin": "Administración",
@@ -548,7 +542,6 @@
"person_alias_btn_delete": "Eliminar",
"error_alias_not_found": "No se encontro el alias de nombre.",
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
"error_person_rename_conflict": "Otra edición modificó un bloque de transcripción referenciado al mismo tiempo. Por favor, inténtalo de nuevo.",
"validation_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
@@ -914,80 +907,5 @@
"bulk_edit_loading": "Cargando documentos…",
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
"bulk_edit_topbar_title": "Edición masiva",
"bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico",
"error_relationship_not_found": "La relación no fue encontrada.",
"error_circular_relationship": "Esta relación crearía un ciclo.",
"error_duplicate_relationship": "Esta relación ya existe.",
"relation_parent_of": "Progenitor de",
"relation_child_of": "Hijo/a de",
"relation_spouse_of": "Cónyuge",
"relation_sibling_of": "Hermano/a",
"relation_friend": "Amigo/a",
"relation_colleague": "Colega",
"relation_employer": "Empleador",
"relation_doctor": "Médico",
"relation_neighbor": "Vecino/a",
"relation_other": "Otro",
"relation_inferred_parent": "Progenitor",
"relation_inferred_child": "Hijo/a",
"relation_inferred_spouse": "Cónyuge",
"relation_inferred_sibling": "Hermano/a",
"relation_inferred_grandparent": "Abuelo/a",
"relation_inferred_grandchild": "Nieto/a",
"relation_inferred_great_grandparent": "Bisabuelo/a",
"relation_inferred_great_grandchild": "Bisnieto/a",
"relation_inferred_uncle_aunt": "Tío/Tía",
"relation_inferred_niece_nephew": "Sobrino/a",
"relation_inferred_great_uncle_aunt": "Tío/a abuelo/a",
"relation_inferred_great_niece_nephew": "Sobrino/a nieto/a",
"relation_inferred_inlaw_parent": "Suegro/a",
"relation_inferred_inlaw_child": "Yerno/Nuera",
"relation_inferred_sibling_inlaw": "Cuñado/a",
"relation_inferred_cousin_1": "Primo/a",
"relation_inferred_distant": "Pariente lejano",
"doc_details_field_relationship": "Parentesco",
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
"stammbaum_empty_link": "→ Ir a la lista de personas",
"stammbaum_panel_direct_rels": "Relaciones directas",
"stammbaum_panel_derived_rels": "Relaciones derivadas",
"stammbaum_panel_to_person": "Ir a la persona →",
"stammbaum_panel_add_rel": "+ Añadir relación",
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
"stammbaum_zoom_in": "Acercar",
"stammbaum_zoom_out": "Alejar",
"stammbaum_generations": "Generaciones",
"relation_error_duplicate": "Esta relación ya existe.",
"relation_error_circular": "Esta relación crearía un ciclo.",
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
"relation_year_from": "desde {year}",
"relation_year_to": "hasta {year}",
"relation_year_error_bis_before_von": "El año final no puede ser anterior al año inicial.",
"relation_label_family_member": "Miembro de la familia",
"relation_toggle_add_to_tree": "Añadir al árbol genealógico",
"relation_toggle_remove_from_tree": "Quitar del árbol genealógico",
"relation_label_in_tree": "Aparece en el árbol genealógico",
"relation_label_view_in_tree": "Ver →",
"relation_label_direct": "Relaciones directas",
"relation_label_derived": "Relaciones derivadas",
"relation_btn_add": "Añadir",
"relation_btn_save": "Guardar",
"relation_btn_cancel": "Cancelar",
"relation_form_group_family": "Familia",
"relation_form_group_social": "Social",
"relation_form_field_type": "Tipo",
"relation_form_field_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920",
"person_relationships_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones."
"bulk_edit_count_pill": "Se editarán {count}"
}

View File

@@ -1,202 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = {
relatedPersonId: string;
relationType: RelationType;
fromYear?: number;
toYear?: number;
};
interface Props {
personId: string;
onSubmit?: (data: RelFormData) => Promise<void>;
}
let { personId, onSubmit }: Props = $props();
let open = $state(false);
let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state('');
let addRelatedPersonName = $state('');
let addFromYear = $state('');
let addToYear = $state('');
let callbackError = $state<string | null>(null);
const yearError = $derived.by(() => {
const from = addFromYear.trim();
const to = addToYear.trim();
if (!from || !to) return null;
const fromInt = parseInt(from, 10);
const toInt = parseInt(to, 10);
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
});
const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
);
const submitDisabled = $derived(
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
function reset() {
addType = 'PARENT_OF';
addRelatedPersonId = '';
addRelatedPersonName = '';
addFromYear = '';
addToYear = '';
callbackError = null;
}
function cancel() {
open = false;
reset();
}
async function handleCallbackSubmit(event: Event) {
event.preventDefault();
if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
const from = parseInt(addFromYear.trim(), 10);
if (!Number.isNaN(from)) data.fromYear = from;
const to = parseInt(addToYear.trim(), 10);
if (!Number.isNaN(to)) data.toYear = to;
try {
await onSubmit(data);
open = false;
reset();
} catch {
callbackError = m.error_internal_error();
}
}
</script>
{#snippet formFields()}
<div class="grid gap-3 md:grid-cols-2">
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_type()}</span>
<select
name="relationType"
bind:value={addType}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
>
<optgroup label={m.relation_form_group_family()}>
<option value="PARENT_OF">{m.relation_parent_of()}</option>
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
</optgroup>
<optgroup label={m.relation_form_group_social()}>
<option value="FRIEND">{m.relation_friend()}</option>
<option value="COLLEAGUE">{m.relation_colleague()}</option>
<option value="EMPLOYER">{m.relation_employer()}</option>
<option value="DOCTOR">{m.relation_doctor()}</option>
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
<option value="OTHER">{m.relation_other()}</option>
</optgroup>
</select>
</label>
<div>
<PersonTypeahead
name="relatedPersonId"
label="Person"
bind:value={addRelatedPersonId}
initialName={addRelatedPersonName}
excludePersonId={personId}
compact
/>
</div>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2"
>{m.relation_form_field_from_year()}</span
>
<input
type="text"
name="fromYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addFromYear}
placeholder={m.relation_form_year_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
>
<input
type="text"
name="toYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addToYear}
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
{#if yearError}
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
{yearError}
</p>
{/if}
</label>
</div>
{#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if}
{#if callbackError}
<p class="mt-2 text-xs text-red-700" role="alert">{callbackError}</p>
{/if}
<div class="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onclick={cancel}
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
>
{m.relation_btn_cancel()}
</button>
<button
type="submit"
disabled={submitDisabled}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
>
{m.relation_btn_add()}
</button>
</div>
{/snippet}
{#if !open}
<button
type="button"
onclick={() => (open = true)}
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
>
{m.stammbaum_panel_add_rel()}
</button>
{:else if onSubmit}
<form onsubmit={handleCallbackSubmit} class="mt-3 rounded-sm border border-line bg-muted/40 p-3">
{@render formFields()}
</form>
{:else}
<form
method="POST"
action="?/addRelationship"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') {
open = false;
reset();
}
};
}}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
>
{@render formFields()}
</form>
{/if}

View File

@@ -1,65 +0,0 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AddRelationshipForm from './AddRelationshipForm.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup);
describe('AddRelationshipForm', () => {
it('shows add-relationship button initially and no form', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
await expect.element(page.getByRole('button')).toBeInTheDocument();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('shows relationType select when add button is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
it('hides form and shows button when cancel is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
);
cancelBtn!.click();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('submit is disabled when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
});
it('form has no server action when onSubmit prop is provided', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const form = document.querySelector('form');
expect(form?.hasAttribute('action')).toBe(false);
});
it('shows year-range error when toYear is before fromYear', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935';
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
toInput.value = '1920';
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
await expect.element(page.getByRole('alert')).toBeVisible();
});
});

View File

@@ -3,7 +3,6 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
@@ -15,18 +14,9 @@ type Props = {
sender: Person | null;
receivers: Person[];
tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
};
let {
documentDate,
location,
status,
sender,
receivers,
tags,
inferredRelationship = null
}: Props = $props();
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
@@ -47,7 +37,7 @@ function getFullName(person: Person): string {
}
</script>
{#snippet personCard(person: Person, relationLabel: string | null = null)}
{#snippet personCard(person: Person)}
<a
href="/persons/{person.id}"
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
@@ -59,10 +49,7 @@ function getFullName(person: Person): string {
>
{getInitials(person.displayName)}
</span>
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
{#if relationLabel}
<RelationshipPill label={relationLabel} />
{/if}
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
</a>
{/snippet}
@@ -101,7 +88,7 @@ function getFullName(person: Person): string {
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_sender()}
</p>
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
{@render personCard(sender)}
</div>
{/if}
{#if receivers.length > 0}
@@ -110,16 +97,8 @@ function getFullName(person: Person): string {
{m.doc_details_field_receivers()}
</p>
<div class="space-y-0.5">
{#each displayedReceivers as receiver, i (receiver.id)}
{@render personCard(
receiver,
// Badge only shown when there is exactly one receiver — with multiple
// receivers the inferred label is computed from the sender's viewpoint
// and cannot be attributed to a specific receiver.
i === 0 && receivers.length === 1
? (inferredRelationship?.labelFromB ?? null)
: null
)}
{#each displayedReceivers as receiver (receiver.id)}
{@render personCard(receiver)}
{/each}
</div>
{#if hiddenReceiverCount > 0 && !showAllReceivers}

View File

@@ -81,25 +81,6 @@ describe('DocumentMetadataDrawer — persons column', () => {
renderDrawer({ sender: null, receivers: [] });
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
});
it('renders inferred relationship pills inline next to sender and receiver', async () => {
renderDrawer({
receivers: [receivers[0]],
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
});
// Sender link contains its pill, receiver link contains its pill.
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
await expect.element(senderLink).toBeInTheDocument();
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
await expect.element(receiverLink).toBeInTheDocument();
});
it('omits the pills when no inferred relationship is provided', async () => {
renderDrawer();
const elternteil = page.getByText('Elternteil');
expect(await elternteil.elements()).toHaveLength(0);
});
});
// ─── Tags column ─────────────────────────────────────────────────────────────

View File

@@ -30,16 +30,9 @@ type Props = {
canWrite: boolean;
fileUrl: string;
transcribeMode: boolean;
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
};
let {
doc,
canWrite,
fileUrl,
transcribeMode = $bindable(),
inferredRelationship = null
}: Props = $props();
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
let detailsOpen = $state(false);
@@ -282,7 +275,6 @@ let mobileMenuOpen = $state(false);
sender={doc.sender ?? null}
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
inferredRelationship={inferredRelationship}
/>
</div>
{/if}

View File

@@ -1,240 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
export type LoadState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
type Props = {
personId: string;
cardId: string;
position: { top: number; left: number };
state: LoadState;
};
let { personId, cardId, position, state }: Props = $props();
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
'PARENT_OF',
'SPOUSE_OF',
'SIBLING_OF'
]);
const NOTES_MAX = 120;
const familyChips = $derived(
state.status === 'loaded'
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
: []
);
const dateRange = $derived(
state.status === 'loaded'
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
: ''
);
const notesExcerpt = $derived.by(() => {
if (state.status !== 'loaded') return null;
const notes = state.person.notes;
if (!notes) return null;
if (notes.length <= NOTES_MAX) return notes;
return notes.slice(0, NOTES_MAX) + '…';
});
</script>
<div
class="person-hover-card"
data-testid="person-hover-card"
id={cardId}
role="region"
aria-live="polite"
style:position="absolute"
style:top={`${position.top}px`}
style:left={`${position.left}px`}
>
{#if state.status === 'loading'}
<div data-testid="person-hover-card-skeleton" class="skeleton">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
{:else if state.status === 'error'}
<div data-testid="person-hover-card-error" class="error-message">
{m.person_mention_load_error()}
</div>
<div class="footer">
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()}</a>
</div>
{:else}
<div data-testid="person-hover-card-content" class="content">
<div class="header">
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
{#if dateRange}
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
{/if}
{#if state.person.alias}
<div class="maiden" data-testid="person-hover-card-maiden">geb. {state.person.alias}</div>
{/if}
</div>
{#if familyChips.length > 0}
<div class="chips" data-testid="person-hover-card-chips">
{#each familyChips as chip (chip.id)}
<span class="chip">{chip.relatedPersonDisplayName}</span>
{/each}
</div>
{/if}
{#if notesExcerpt}
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
{/if}
<div class="footer">
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()}</a>
<span class="hint">{m.person_mention_hover_hint()}</span>
</div>
</div>
{/if}
</div>
<style>
.person-hover-card {
width: 320px;
min-height: 180px;
background-color: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 14px 16px;
font-family: var(--font-sans);
font-size: 14px;
color: var(--c-ink);
z-index: 50;
}
/* On touch devices the card is suppressed entirely — tap navigates directly. */
@media (hover: none) {
.person-hover-card {
display: none;
}
}
.skeleton {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0;
}
.skeleton .bar {
height: 14px;
border-radius: 4px;
background-color: var(--c-line);
animation: pulse 1.4s ease-in-out infinite;
}
.skeleton .bar:nth-child(1) {
width: 60%;
}
.skeleton .bar:nth-child(2) {
width: 40%;
}
.skeleton .bar:nth-child(3) {
width: 90%;
}
@keyframes pulse {
0% {
opacity: 0.55;
}
50% {
opacity: 1;
}
100% {
opacity: 0.55;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton .bar {
animation: none;
opacity: 0.7;
}
}
.header {
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--c-ink);
color: var(--c-surface);
margin: -14px -16px 12px;
padding: 12px 16px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.name {
font-family: var(--font-serif);
font-weight: 600;
font-size: 16px;
}
.dates,
.maiden {
font-size: 12px;
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
font-size: 12px;
background-color: var(--c-accent-bg);
color: var(--c-ink);
border-radius: 999px;
padding: 2px 10px;
}
.notes {
font-size: 13px;
color: var(--c-ink-2);
line-height: 1.4;
margin: 0 0 10px;
}
.error-message {
font-size: 13px;
color: var(--c-ink-2);
padding: 8px 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--c-line);
padding-top: 8px;
margin-top: 4px;
}
.open-link {
color: var(--c-ink);
text-decoration: underline;
text-underline-offset: 3px;
font-weight: 500;
}
.hint {
font-size: 11px;
color: var(--c-ink-3);
}
</style>

View File

@@ -1,272 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonHoverCard from './PersonHoverCard.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthYear: 1882,
deathYear: 1944
} as unknown as Person;
const POSITION = { top: 100, left: 200 };
afterEach(() => cleanup());
describe('PersonHoverCard — loading state', () => {
it('shows the skeleton when state.status is loading', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
});
it('renders three skeleton bars', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
expect(bars.length).toBe(3);
});
});
describe('PersonHoverCard — error state', () => {
it('shows a generic error message when state.status is error', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'error' }
});
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
});
it('still allows the link footer to navigate (link present in error state)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'error' }
});
// The card root must show the footer link even when the body errored —
// click navigation works regardless of fetch outcome.
const link = document.querySelector('a[href="/persons/p-aug"]');
expect(link).not.toBeNull();
});
});
describe('PersonHoverCard — loaded state', () => {
it('renders the person displayName as the header name', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
it('renders the life-date range when birthYear and deathYear are present', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
it('omits the life-date line when both years are missing', async () => {
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: noDates, relationships: [] }
});
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
expect(dates).toBeNull();
});
it('renders "geb. <alias>" when alias is set', async () => {
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withAlias, relationships: [] }
});
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
});
it('omits the maiden name line when alias is null', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
expect(maiden).toBeNull();
});
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
const relationships: RelationshipDTO[] = [
{
id: 'r1',
personId: 'p-aug',
relatedPersonId: 'p-spouse',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Otto Raddatz',
relationType: 'SPOUSE_OF'
},
{
id: 'r2',
personId: 'p-aug',
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND'
},
{
id: 'r3',
personId: 'p-aug',
relatedPersonId: 'p-sibling',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Marie Sister',
relationType: 'SIBLING_OF'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships }
});
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
// Non-family relationship type must be filtered out
const friendChip = page.getByText('Karl Friend');
await expect.element(friendChip).not.toBeInTheDocument();
});
it('omits the chips section entirely when no family relationships', async () => {
const onlyFriend: RelationshipDTO[] = [
{
id: 'r1',
personId: 'p-aug',
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
});
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
expect(chips).toBeNull();
});
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withNotes, relationships: [] }
});
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
});
it('truncates notes longer than 120 characters with an ellipsis', async () => {
const long = 'x'.repeat(150);
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withLongNotes, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
expect(notes.textContent!.length).toBeLessThanOrEqual(122);
expect(notes.textContent).toContain('…');
});
it('omits notes section when notes is null', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
expect(notes).toBeNull();
});
it('footer renders an anchor link to /persons/{personId}', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const link = document.querySelector('a[href="/persons/p-aug"]')!;
expect(link).not.toBeNull();
});
});
describe('PersonHoverCard — accessibility', () => {
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.getAttribute('aria-live')).toBe('polite');
});
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-xyz',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.id).toBe('card-xyz');
});
it('positions itself absolutely at the given top/left', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: { top: 333, left: 444 },
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
expect(root.style.top).toBe('333px');
expect(root.style.left).toBe('444px');
expect(root.style.position).toBe('absolute');
});
});

View File

@@ -1,263 +0,0 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { detectPersonMention } from '$lib/utils/personMention';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
type Props = {
value: string;
mentionedPersons: PersonMention[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onfocus?: () => void;
onblur?: () => void;
// Optional escape hatch: lets the parent observe the underlying textarea node
// (e.g. to read selection bounds for quote-selection features). Returning a
// cleanup function from the parent is not required.
captureTextarea?: (node: HTMLTextAreaElement) => void | (() => void);
};
let {
value = $bindable(''),
mentionedPersons = $bindable([]),
placeholder = '',
rows = 1,
disabled = false,
onfocus,
onblur,
captureTextarea
}: Props = $props();
let query: string | null = $state(null);
let results: Person[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let loading = $state(false);
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
resizeTextarea();
const parentCleanup = captureTextarea?.(node);
return () => {
parentCleanup?.();
textarea = null;
};
}
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
// Autoresize on every value change — read `value` so this $effect
// re-runs whenever the bound prop is reassigned.
$effect(() => {
void value;
resizeTextarea();
});
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectPersonMention(value, cursorPos);
if (detected === null) {
closePopup();
return;
}
const before = value.slice(0, cursorPos);
mentionStart = before.lastIndexOf('@');
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q.trim()) {
// Empty query: keep popup open with last results so the user can browse,
// but don't fire a backend call until they actually type something.
results = [];
loading = false;
return;
}
loading = true;
debounceTimer = setTimeout(async () => {
try {
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
// cookie as the Authorization header (vite.config.ts) and on the
// browser's same-origin policy for the /api/* path. Mounted in
// transcribe mode behind WRITE_ALL — never reachable to unauthenticated
// users.
const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: Person[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
}
} catch {
results = [];
} finally {
loading = false;
}
}, 200);
}
async function selectPerson(person: Person) {
if (!textarea) return;
const displayName = person.displayName ?? '';
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
if (!mentionedPersons.some((existing) => existing.personId === person.id)) {
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }];
}
closePopup();
await tick();
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}
function closePopup() {
query = null;
results = [];
highlightedIndex = 0;
loading = false;
clearTimeout(debounceTimer);
}
function handleBlur() {
// Small delay so an option's onmousedown can fire and select before the
// popup unmounts. Without this, clicking a result on the way out would
// race with blur and lose the selection.
setTimeout(() => closePopup(), 150);
onblur?.();
}
function handleKeydown(e: KeyboardEvent) {
if (query === null) return;
if (e.key === 'Escape') {
e.preventDefault();
closePopup();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex + 1) % results.length;
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
}
return;
}
if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
selectPerson(results[highlightedIndex]);
return;
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="block min-h-[44px] w-full resize-none rounded-sm border border-transparent bg-transparent px-1 py-2.5 font-serif text-base leading-relaxed text-ink placeholder:text-ink-3 focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40 focus-visible:outline-none"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={onfocus}
onblur={handleBlur}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
>
{#if loading}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.comp_typeahead_loading()}</p>
{:else if results.length === 0}
<div class="flex flex-col gap-2 px-3 py-2.5">
<p class="font-sans text-sm text-ink-3">{m.person_mention_popup_empty()}</p>
<a
href="/persons/new?name={encodeURIComponent(query ?? '')}"
target="_blank"
rel="noopener"
class="font-sans text-sm font-medium text-brand-navy underline-offset-2 hover:underline"
>
{m.person_mention_create_new()}
</a>
</div>
{:else}
{#each results as person, i (person.id)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
// Keyboard-highlighted row gets a stronger token than hover so
// keyboard users (and tablet stylus users sweeping over rows)
// can tell the cursor position apart from a hover (Leonie #5507 §3,
// WCAG 1.4.11 Non-Text Contrast).
i === highlightedIndex &&
'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
e.preventDefault();
selectPerson(person);
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -1,385 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
// Editor's internal search debounce is 200ms — drive it via fake timers
// so tests are deterministic and fast (Tester #5506 §1).
const DEBOUNCE_MS = 200;
async function flushDebounce() {
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
// Let the awaited fetch resolve and the resulting state assignments flush.
await vi.runAllTimersAsync();
}
async function tick() {
await vi.advanceTimersByTimeAsync(0);
}
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
birthYear: 1882,
deathYear: 1944
} as unknown as Person;
const ANNA: Person = {
id: 'p-anna',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
birthYear: 1860
} as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) });
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function mockFetchEmpty() {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function mockFetchRejects() {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function getTextarea(): HTMLTextAreaElement {
return document.querySelector('textarea')!;
}
function clickOption(personId: string) {
const opt = document.querySelector(
`[role="option"][data-test-person-id="${personId}"]`
) as HTMLElement;
opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
}
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
let snapshot: Snapshot = {
value: initial.value ?? '',
mentionedPersons: initial.mentionedPersons ?? []
};
render(PersonMentionEditorHost, {
initialValue: initial.value ?? '',
initialMentions: initial.mentionedPersons ?? [],
onChange: (snap: Snapshot) => {
snapshot = snap;
}
});
return {
get snapshot() {
return snapshot;
}
};
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMentionEditor — rendering', () => {
it('renders the textarea with placeholder', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
placeholder: 'Transkription…',
onChange: () => {}
});
await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument();
});
it('reflects bound initial value', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Hallo Welt',
initialMentions: [],
onChange: () => {}
});
await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
});
});
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
describe('PersonMentionEditor — typeahead', () => {
it('opens the popup when typing @ + query and shows results', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
});
it('shows life dates next to the name in the dropdown', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
it('shows empty state when no persons match', async () => {
mockFetchEmpty();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@xyz';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => {
mockFetchRejects();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
});
it('keeps the popup open when the query has a trailing space (multi-word names)', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Auguste ';
ta.selectionStart = 9;
ta.selectionEnd = 9;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
});
// ─── Selection writes text + sidecar ─────────────────────────────────────────
describe('PersonMentionEditor — selecting a person', () => {
it('inserts @DisplayName followed by a trailing space into the textarea', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug');
await tick();
expect(host.snapshot.value).toBe('@Auguste Raddatz ');
});
it('pushes {personId, displayName} into the bound mentionedPersons array', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug');
await tick();
expect(host.snapshot.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
]);
});
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons();
const host = renderHost({
value: '@Auguste Raddatz ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
});
const ta = getTextarea();
ta.focus();
ta.value = '@Auguste Raddatz @Aug';
ta.selectionStart = ta.value.length;
ta.selectionEnd = ta.value.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
clickOption('p-aug');
await tick();
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
it('ArrowDown / ArrowUp cycle the highlighted result', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@A';
ta.selectionStart = 2;
ta.selectionEnd = 2;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
const optAuguste = document.querySelector(
'[role="option"][data-test-person-id="p-aug"]'
) as HTMLElement;
const optAnna = document.querySelector(
'[role="option"][data-test-person-id="p-anna"]'
) as HTMLElement;
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
expect(optAnna.getAttribute('aria-selected')).toBe('false');
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await tick();
expect(optAuguste.getAttribute('aria-selected')).toBe('false');
expect(optAnna.getAttribute('aria-selected')).toBe('true');
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await tick();
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
expect(optAnna.getAttribute('aria-selected')).toBe('false');
});
it('Enter selects the currently highlighted result', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@A';
ta.selectionStart = 2;
ta.selectionEnd = 2;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await tick();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await tick();
expect(host.snapshot.mentionedPersons).toEqual([
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
]);
});
it('Escape closes the popup without inserting anything', async () => {
mockFetchWithPersons();
const host = renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await tick();
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
expect(host.snapshot.value).toBe('@Aug');
expect(host.snapshot.mentionedPersons).toEqual([]);
});
});
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
describe('PersonMentionEditor — touch target', () => {
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
mockFetchWithPersons();
renderHost();
const ta = getTextarea();
ta.focus();
ta.value = '@Aug';
ta.selectionStart = 4;
ta.selectionEnd = 4;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await flushDebounce();
const option = document.querySelector('[role="option"]') as HTMLElement;
expect(option).not.toBeNull();
expect(option.className).toContain('min-h-[44px]');
});
});

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { components } from '$lib/generated/api';
type PersonMention = components['schemas']['PersonMention'];
type Props = {
initialValue?: string;
initialMentions?: PersonMention[];
placeholder?: string;
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
};
let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props();
// initial* props seed mount-time state; reading them inside untrack signals
// the intentional one-shot capture and silences state_referenced_locally.
let value = $state(untrack(() => initialValue));
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
$effect(() => {
onChange({ value, mentionedPersons: [...mentionedPersons] });
});
</script>
<PersonMentionEditor
bind:value={value}
bind:mentionedPersons={mentionedPersons}
placeholder={placeholder}
/>

View File

@@ -12,25 +12,16 @@ const PERSONS = [
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
},
{
id: '3',
firstName: 'Karl',
lastName: 'König',
displayName: 'Karl König',
personType: 'PERSON',
familyMember: false
}
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
];
function mockFetch(persons = PERSONS) {
@@ -71,16 +62,14 @@ describe('PersonMultiSelect rendering', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
}
]
});
@@ -97,16 +86,14 @@ describe('PersonMultiSelect rendering', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
}
]
});
@@ -125,8 +112,7 @@ describe('PersonMultiSelect rendering', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
}
]
});
@@ -180,8 +166,7 @@ describe('PersonMultiSelect selecting persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
}
]
});
@@ -202,8 +187,7 @@ describe('PersonMultiSelect selecting persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
}
]);
render(PersonMultiSelect, { selectedPersons: [] });
@@ -226,16 +210,14 @@ describe('PersonMultiSelect removing persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
}
]
});
@@ -254,16 +236,14 @@ describe('PersonMultiSelect removing persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
personType: 'PERSON'
}
]
});

View File

@@ -19,7 +19,6 @@ interface Props {
autofocus?: boolean;
required?: boolean;
restrictToCorrespondentsOf?: string;
excludePersonId?: string;
badge?: 'additive' | 'replace';
onchange?: (value: string) => void;
onfocused?: () => void;
@@ -37,7 +36,6 @@ let {
autofocus = false,
required = false,
restrictToCorrespondentsOf,
excludePersonId,
badge,
onchange,
onfocused
@@ -63,40 +61,21 @@ $effect(() => {
const typeahead = createTypeahead<Person>({
fetchUrl: async (term) => {
const personId = restrictToCorrespondentsOf;
const excludeId = excludePersonId;
const filter = (results: Person[]) =>
excludeId ? results.filter((p) => p.id !== excludeId) : results;
if (personId) {
const url =
term.length >= 1
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
: `/api/persons/${personId}/correspondents`;
const res = await fetch(url);
return res.ok ? filter(await res.json()) : [];
return res.ok ? await res.json() : [];
}
if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
return res.ok ? filter(await res.json()) : [];
return res.ok ? await res.json() : [];
},
debounceMs: 300
});
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
let activeIndex = $state(-1);
// Stable id linking the input's aria-controls to the listbox element.
const listboxId = `${name}-listbox`;
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
if (value && searchTerm !== initialName) {
value = '';
@@ -109,7 +88,6 @@ function handleInput() {
function handleFocus() {
onfocused?.();
updateDropdownPosition();
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
(async () => {
@@ -131,47 +109,13 @@ function selectPerson(person: Person) {
value = person.id!;
searchTerm = person.displayName;
typeahead.close();
activeIndex = -1;
onchange?.(person.id!);
}
function closeDropdown() {
typeahead.close();
activeIndex = -1;
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) return;
const results = typeahead.results;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % results.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + results.length) % results.length;
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) {
selectPerson(results[activeIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
}
}
// Keep dropdown position current when user scrolls or resizes.
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<label
for="{name}-search"
for={name}
class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: 'block text-sm font-medium text-ink-2'}
@@ -181,22 +125,13 @@ function handleKeydown(e: KeyboardEvent) {
<input type="hidden" name={name} bind:value={value} />
<input
bind:this={inputEl}
type="text"
id="{name}-search"
role="combobox"
autocomplete="off"
autofocus={autofocus}
bind:value={searchTerm}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
: undefined}
oninput={handleInput}
onfocus={handleFocus}
onkeydown={handleKeydown}
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
class={large
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
@@ -205,34 +140,29 @@ function handleKeydown(e: KeyboardEvent) {
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/>
{#if isOpen}
<ul
id={listboxId}
role="listbox"
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
<div
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#if typeahead.loading}
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
{:else}
{#each typeahead.results as person, i (person.id)}
<li
id="{listboxId}-option-{person.id}"
role="option"
aria-selected={i === activeIndex}
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
{#each typeahead.results as person (person.id)}
<div
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
tabindex="-1"
role="button"
tabindex="0"
>
<div class="flex items-center">
<span class="block truncate font-medium">
{person.displayName}
</span>
</div>
</li>
</div>
{/each}
{/if}
</ul>
</div>
{/if}
</div>

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { page } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -130,11 +130,11 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect
.element(page.getByRole('option', { name: 'Max Mustermann' }))
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -145,7 +145,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await tick();
expect(hiddenInput('senderId')?.value).toBe('1');
@@ -158,7 +158,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
});
@@ -177,7 +177,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
});
@@ -194,7 +194,7 @@ describe('PersonTypeahead clearing a selection', () => {
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear();
@@ -285,194 +285,3 @@ describe('PersonTypeahead click outside', () => {
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});
// ─── ARIA roles ───────────────────────────────────────────────────────────────
describe('PersonTypeahead ARIA roles', () => {
it('dropdown uses role="listbox" container and role="option" items', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
// Container must be a listbox
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
// Items must be options, not buttons
const options = page.getByRole('option');
await expect.element(options.first()).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
});
it('input has aria-expanded="false" when dropdown is closed', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
});
it('input has aria-expanded="true" when dropdown is open', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
});
it('input has aria-controls pointing to the listbox id', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
const ariaControls = await input.element().getAttribute('aria-controls');
expect(ariaControls).toBeTruthy();
// The listbox with that id must exist
const listbox = document.getElementById(ariaControls!);
expect(listbox).not.toBeNull();
expect(listbox!.getAttribute('role')).toBe('listbox');
});
it('input has aria-haspopup="listbox"', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
});
});
// ─── Keyboard navigation ──────────────────────────────────────────────────────
describe('PersonTypeahead keyboard navigation', () => {
it('ArrowDown moves highlight to the first option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
// First option should be highlighted (aria-selected="true")
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
await userEvent.keyboard('{ArrowDown}');
await tick();
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowUp from first wraps to last option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}'); // highlight first
await tick();
await userEvent.keyboard('{ArrowUp}'); // wrap to last
await tick();
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowDown from last wraps to first option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
await tick();
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
await tick();
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
await tick();
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('Enter selects the highlighted option', async () => {
mockFetchWithPersons([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
await userEvent.keyboard('{Enter}');
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('Escape closes the dropdown without selecting', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await input.click();
await userEvent.keyboard('{Escape}');
await tick();
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
// Value unchanged — nothing was selected
await expect.element(input).toHaveValue('Mu');
});
it('active option id is set as aria-activedescendant on the input', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
// No active option before pressing ArrowDown
const beforeNav = await input.element().getAttribute('aria-activedescendant');
expect(beforeNav).toBeFalsy();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
const afterNav = await input.element().getAttribute('aria-activedescendant');
expect(afterNav).toBeTruthy();
});
});

View File

@@ -1,49 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
interface Props {
chipLabel: string;
otherName: string;
yearRange?: string;
canWrite: boolean;
relId: string;
}
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
</script>
<li class="flex items-center gap-2 py-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{chipLabel}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName}
</span>
{#if yearRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
{/if}
{#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
<input type="hidden" name="relId" value={relId} />
<button
type="submit"
aria-label="{m.btn_delete()}{otherName}"
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-red-600"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
{/if}
</li>

View File

@@ -1,55 +0,0 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import RelationshipChip from './RelationshipChip.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
afterEach(cleanup);
const baseProps = {
chipLabel: 'Elternteil',
otherName: 'Anna Schmidt',
yearRange: '',
canWrite: false,
relId: 'rel-1'
};
describe('RelationshipChip', () => {
it('renders the chip label', async () => {
render(RelationshipChip, baseProps);
await expect.element(page.getByText('Elternteil')).toBeInTheDocument();
});
it('renders the other person name', async () => {
render(RelationshipChip, baseProps);
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
it('shows year range when provided', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '19201980' });
await expect.element(page.getByText('19201980')).toBeInTheDocument();
});
it('does not show year range span when empty', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '' });
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
});
it('shows delete button when canWrite is true', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
await expect.element(page.getByRole('button')).toBeInTheDocument();
});
it('hides delete button when canWrite is false', async () => {
render(RelationshipChip, { ...baseProps, canWrite: false });
expect(document.querySelector('button')).toBeNull();
});
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
const btn = document.querySelector('button')!;
expect(btn.className).toContain('h-11');
expect(btn.className).toContain('w-11');
});
});

View File

@@ -1,10 +0,0 @@
<script lang="ts">
type Props = { label: string };
let { label }: Props = $props();
</script>
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent bg-accent/25 px-2 py-px font-sans text-[10px] font-bold tracking-[0.07em] text-ink uppercase"
>
{label}
</span>

View File

@@ -1,174 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import RelationshipChip from '$lib/components/RelationshipChip.svelte';
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
personId: string;
familyMember: boolean;
relationships: RelationshipDTO[];
inferredRelationships: InferredRelationshipWithPersonDTO[];
canWrite: boolean;
relationshipError?: string | null;
}
let {
personId,
familyMember,
relationships,
inferredRelationships,
canWrite,
relationshipError = null
}: Props = $props();
type RelationType = NonNullable<RelationshipDTO['relationType']>;
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
const topDerived = $derived(inferredRelationships.slice(0, 5));
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
if (order !== 0) return order;
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
}
function relationTypeOrder(t: RelationType | undefined): number {
const order: Record<string, number> = {
PARENT_OF: 1,
SPOUSE_OF: 2,
SIBLING_OF: 3,
FRIEND: 4,
COLLEAGUE: 5,
EMPLOYER: 6,
DOCTOR: 7,
NEIGHBOR: 8,
OTHER: 9
};
return order[t ?? 'OTHER'] ?? 99;
}
function yearRange(rel: RelationshipDTO): string {
const from = rel.fromYear;
const to = rel.toYear;
if (from && to) return `${from}${to}`;
if (from) return m.relation_year_from({ year: from });
if (to) return m.relation_year_to({ year: to });
return '';
}
</script>
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
<!-- Header row: heading + family-member toggle -->
<div class="mb-5 flex items-start justify-between gap-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_relationships_heading()}
</h2>
{#if canWrite}
<form method="POST" action="?/toggleFamilyMember" use:enhance>
<input type="hidden" name="familyMember" value={familyMember ? 'false' : 'true'} />
<button
type="submit"
role="switch"
aria-checked={familyMember}
aria-label={familyMember
? m.relation_toggle_remove_from_tree()
: m.relation_toggle_add_to_tree()}
class="inline-flex items-center gap-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
<span
class="relative inline-block h-4 w-7 rounded-full transition-colors {familyMember
? 'bg-primary'
: 'bg-line'}"
>
<span
class="absolute top-0.5 left-0.5 inline-block h-3 w-3 rounded-full bg-white transition-transform {familyMember
? 'translate-x-3'
: ''}"
></span>
</span>
{m.relation_label_family_member()}
</button>
</form>
{/if}
</div>
{#if relationshipError}
<p class="mb-3 text-sm text-red-700" role="alert">{relationshipError}</p>
{/if}
<!-- In-tree banner -->
{#if familyMember}
<div
class="mb-4 flex items-center justify-between rounded-sm border border-accent/30 bg-accent/10 px-3 py-2"
>
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full bg-accent"></span>
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_in_tree()}</span>
</div>
<a
href="/stammbaum?focus={personId}"
class="font-sans text-xs font-medium text-primary hover:underline"
>
{m.relation_label_view_in_tree()}
</a>
</div>
{/if}
<!-- Direkte Beziehungen -->
<h3 class="mb-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.relation_label_direct()}
</h3>
{#if sortedDirect.length === 0}
<p class="mb-2 text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="mb-2 divide-y divide-line">
{#each sortedDirect as rel (rel.id)}
<RelationshipChip
chipLabel={chipLabel(rel, personId)}
otherName={otherName(rel, personId)}
yearRange={yearRange(rel)}
canWrite={canWrite}
relId={rel.id}
/>
{/each}
</ul>
{/if}
{#if canWrite}
<AddRelationshipForm personId={personId} />
{/if}
<!-- Abgeleitete Beziehungen -->
{#if topDerived.length > 0}
<details class="mt-6">
<summary
class="cursor-pointer text-xs font-bold tracking-widest text-ink-3 uppercase select-none"
>
{m.relation_label_derived()}
</summary>
<ul class="mt-2 space-y-2">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<a
href="/persons/{derived.person.id}"
class="min-w-0 flex-1 truncate font-serif text-sm text-ink-2 hover:underline"
>
{derived.person.displayName}
</a>
</li>
{/each}
</ul>
</details>
{/if}
</div>

View File

@@ -1,54 +0,0 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import StammbaumCard from './StammbaumCard.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/components/RelationshipChip.svelte', () => ({ default: () => null }));
vi.mock('$lib/components/AddRelationshipForm.svelte', () => ({ default: () => null }));
afterEach(cleanup);
const baseProps = {
personId: 'person-1',
familyMember: false,
relationships: [],
inferredRelationships: [],
canWrite: false
};
describe('StammbaumCard', () => {
it('renders the section heading', async () => {
render(StammbaumCard, baseProps);
await expect.element(page.getByText('Stammbaum & Beziehungen')).toBeInTheDocument();
});
it('shows empty-relationships message when relationships list is empty', async () => {
render(StammbaumCard, baseProps);
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
});
it('renders the family-member toggle when canWrite is true', async () => {
render(StammbaumCard, { ...baseProps, canWrite: true });
await expect.element(page.getByText('Als Familienmitglied')).toBeInTheDocument();
});
it('displays relationshipError text when provided', async () => {
render(StammbaumCard, { ...baseProps, relationshipError: 'Test Fehler' });
await expect.element(page.getByText('Test Fehler')).toBeInTheDocument();
});
it('toggle aria-label says "Zum Stammbaum hinzufügen" when not yet a family member', async () => {
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: false });
await expect
.element(page.getByRole('switch', { name: 'Zum Stammbaum hinzufügen' }))
.toBeInTheDocument();
});
it('toggle aria-label says "Aus Stammbaum entfernen" when already a family member', async () => {
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: true });
await expect
.element(page.getByRole('switch', { name: 'Aus Stammbaum entfernen' }))
.toBeInTheDocument();
});
});

View File

@@ -1,183 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
import type { RelFormData } from '$lib/components/AddRelationshipForm.svelte';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
node: PersonNodeDTO;
onClose: () => void;
canWrite?: boolean;
}
let { node, onClose, canWrite = false }: Props = $props();
let directRels = $state<RelationshipDTO[]>([]);
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
const id = node.id;
loadFor(id);
});
async function loadFor(id: string) {
loading = true;
error = null;
try {
const [directRes, derivedRes] = await Promise.all([
fetch(`/api/persons/${id}/relationships`),
fetch(`/api/persons/${id}/inferred-relationships`)
]);
if (!directRes.ok || !derivedRes.ok) {
error = m.error_internal_error();
return;
}
directRels = await directRes.json();
derivedRels = await derivedRes.json();
} catch {
error = m.error_internal_error();
} finally {
loading = false;
}
}
async function handleAddRelationship(data: RelFormData) {
const body: Record<string, string | number> = {
relatedPersonId: data.relatedPersonId,
relationType: data.relationType
};
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
if (data.toYear !== undefined) body.toYear = data.toYear;
const res = await fetch(`/api/persons/${node.id}/relationships`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('Failed to add relationship');
await loadFor(node.id);
await invalidateAll();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
onMount(() => {
const handler = (e: KeyboardEvent) => handleEscape(e);
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const directOtherIds = $derived(
new Set(directRels.map((r) => (r.personId === node.id ? r.relatedPersonId : r.personId)))
);
const topDerived = $derived(
derivedRels.filter((d) => !directOtherIds.has(d.person.id)).slice(0, 5)
);
</script>
<div class="flex h-full flex-col p-5">
<div class="mb-4 flex items-start justify-between gap-2">
<div class="min-w-0">
<h2 class="font-serif text-lg text-ink">{node.displayName}</h2>
{#if node.birthYear || node.deathYear}
<p class="font-sans text-xs text-ink-3">
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</p>
{/if}
</div>
<button
type="button"
onclick={onClose}
aria-label={m.comp_dismiss()}
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
{#if error}
<p class="mb-3 text-sm text-red-700" role="alert">{error}</p>
{:else if loading}
<p class="font-sans text-xs text-ink-3 italic"></p>
{:else}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_direct_rels()}
</h3>
{#if directRels.length === 0}
<p class="text-xs text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="space-y-1.5">
{#each directRels as rel (rel.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{chipLabel(rel, node.id)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink">
{otherName(rel, node.id)}
</span>
</li>
{/each}
</ul>
{/if}
{#if canWrite}
{#key node.id}
<AddRelationshipForm personId={node.id} onSubmit={handleAddRelationship} />
{/key}
{/if}
</section>
{#if topDerived.length > 0}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_derived_rels()}
</h3>
<ul class="space-y-1.5">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink-2">
{derived.person.displayName}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<div class="mt-auto">
<a
href="/persons/{node.id}"
class="block w-full rounded-sm border border-line bg-surface px-3 py-2 text-center font-sans text-xs font-medium text-primary transition hover:bg-muted"
>
{m.stammbaum_panel_to_person()}
</a>
</div>
</div>

View File

@@ -1,90 +0,0 @@
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
const makeNode = () => ({
id: 'person-1',
displayName: 'Alice Müller',
birthYear: 1900,
deathYear: null,
familyMember: true
});
function stubFetch(directRels: unknown[] = [], inferredRels: unknown[] = []) {
let callCount = 0;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => {
callCount++;
const body = callCount % 2 === 1 ? directRels : inferredRels;
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
})
);
}
beforeEach(() => stubFetch());
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('StammbaumSidePanel', () => {
it('calls onClose when dismiss button is clicked', async () => {
const onClose = vi.fn();
render(StammbaumSidePanel, { node: makeNode(), onClose, canWrite: false });
await expect.element(page.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
const btn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.getAttribute('aria-label') === 'Schließen'
);
btn!.click();
expect(onClose).toHaveBeenCalledOnce();
});
it('renders the person displayName as heading', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('Alice Müller')).toBeInTheDocument();
});
it('shows empty-relationships message when no direct relationships are loaded', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
});
it('year inputs inside the add form have label elements (canWrite=true)', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
/Beziehung hinzufügen/i.test(b.textContent ?? '')
);
addBtn!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const yearInputs = [...document.querySelectorAll('input')].filter(
(i) => i.inputMode === 'numeric'
);
expect(yearInputs.length).toBeGreaterThan(0);
for (const input of yearInputs) {
expect(input.closest('label')).not.toBeNull();
}
});
it('shows loading indicator while fetching', async () => {
let resolveFirst: (v: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
})
)
);
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('…')).toBeInTheDocument();
resolveFirst!({ ok: true, json: () => Promise.resolve([]) });
});
});

View File

@@ -1,548 +0,0 @@
<script lang="ts">
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
to a single $derived.by computation; never mutated after layout. */
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
zoom: number;
onSelect: (id: string) => void;
}
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
const NODE_W = 160;
const NODE_H = 56;
const COL_GAP = 40;
const ROW_GAP = 80;
const VIEWBOX_PAD = 80;
// Minimum viewBox dimensions — keeps a single node from being scaled up
// to fill the entire canvas. Roughly matches a typical desktop content area.
const MIN_VIEWBOX_W = 1200;
const MIN_VIEWBOX_H = 800;
type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
viewX: number;
viewY: number;
viewW: number;
viewH: number;
};
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
const viewBox = $derived.by(() => {
const w = layout.viewW / zoom;
const h = layout.viewH / zoom;
const cx = layout.viewX + layout.viewW / 2;
const cy = layout.viewY + layout.viewH / 2;
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
const childToParents = new Map<string, string[]>();
const spousePairs = new Map<string, string>();
for (const e of allEdges) {
switch (e.relationType) {
case 'PARENT_OF':
mapPush(parentToChildren, e.personId, e.relatedPersonId);
mapPush(childToParents, e.relatedPersonId, e.personId);
break;
case 'SPOUSE_OF':
spousePairs.set(e.personId, e.relatedPersonId);
spousePairs.set(e.relatedPersonId, e.personId);
break;
}
}
// Iterative longest-path generation assignment.
//
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
// Then spouses are pulled to share the deeper generation. Pulling a spouse
// down can shift their own descendants, so we iterate until stable rather
// than running BFS once like the previous implementation (which left
// e.g. a child of a "later-pulled" spouse stranded one row too high).
const generation = new Map<string, number>();
for (const n of allNodes) generation.set(n.id, 0);
const maxIters = allNodes.length + 4;
for (let it = 0; it < maxIters; it++) {
let changed = false;
for (const n of allNodes) {
const parents = childToParents.get(n.id) ?? [];
if (parents.length === 0) continue;
let maxParentGen = -1;
for (const pid of parents) {
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
}
const newGen = maxParentGen + 1;
if ((generation.get(n.id) ?? 0) < newGen) {
generation.set(n.id, newGen);
changed = true;
}
}
for (const [a, b] of spousePairs) {
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
if ((generation.get(a) ?? 0) < m) {
generation.set(a, m);
changed = true;
}
if ((generation.get(b) ?? 0) < m) {
generation.set(b, m);
changed = true;
}
}
if (!changed) break;
}
// Group by generation, then sort within generation by display name.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = generation.get(n.id) ?? 0;
if (!generations.has(g)) generations.set(g, []);
generations.get(g)!.push(n.id);
}
const byId = new Map(allNodes.map((n) => [n.id, n]));
for (const ids of generations.values()) {
ids.sort((a, b) => {
const an = byId.get(a)?.displayName ?? '';
const bn = byId.get(b)?.displayName ?? '';
return an.localeCompare(bn);
});
}
// Per-generation layout:
//
// 1. Build sibling-groups (children of the same parent set) — these become
// the layout "blocks" that are centred under their parents' midpoint.
// 2. Attach loose spouses (people with no parents in the graph but a
// spouse who *is* in a sibling group) on the outside of their partner,
// so the spouse line stays short and adjacent.
// 3. Merge dual-loose spouse pairs into a single 2-person block.
// 4. Centre each block such that its *parented* members average sits
// exactly under the parent midpoint (keeping all connectors at 90°),
// then pack blocks left-to-right.
type Block = {
members: { id: string; parented: boolean }[];
center: number;
};
const positions = new Map<string, { x: number; y: number }>();
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (let gi = 0; gi < sortedGens.length; gi++) {
const g = sortedGens[gi];
const ids = generations.get(g)!;
const y = g * (NODE_H + ROW_GAP);
const blocksByKey = new Map<string, Block>();
const memberLookup = new Map<string, { key: string; parented: boolean }>();
// Step 1: place every node with parents-in-graph into a sibling block.
for (const id of ids) {
const parents = childToParents.get(id) ?? [];
if (parents.length === 0) continue;
const blockKey = [...parents].sort().join('|');
let block = blocksByKey.get(blockKey);
if (!block) {
const parentCenters: number[] = [];
for (const pid of parents) {
const p = positions.get(pid);
if (p) parentCenters.push(p.x + NODE_W / 2);
}
const center =
parentCenters.length > 0
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
: 0;
block = { members: [], center };
blocksByKey.set(blockKey, block);
}
block.members.push({ id, parented: true });
memberLookup.set(id, { key: blockKey, parented: true });
}
// Sort members within each sibling block alphabetically.
for (const block of blocksByKey.values()) {
block.members.sort((a, b) =>
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
);
}
// Step 2 + 3: handle loose nodes.
for (const id of ids) {
if (memberLookup.has(id)) continue;
const spouse = spousePairs.get(id);
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
if (spouseLookup && spouseLookup.parented) {
// Spouse is parented — attach this loose node next to them on
// the outer edge of their sibling block so the marriage line
// is short and the sibling order is preserved.
const block = blocksByKey.get(spouseLookup.key)!;
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
const insertOnRight = spouseIdx >= block.members.length / 2;
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
block.members.splice(insertAt, 0, { id, parented: false });
memberLookup.set(id, { key: spouseLookup.key, parented: false });
} else {
// No usable parented spouse: put in its own loose block. We
// merge dual-loose spouse pairs in the next pass.
const blockKey = `__loose__${id}`;
blocksByKey.set(blockKey, {
members: [{ id, parented: false }],
center: 0
});
memberLookup.set(id, { key: blockKey, parented: false });
}
}
// Merge dual-loose spouse blocks into a single 2-person block.
const removed = new Set<string>();
for (const [key, block] of blocksByKey) {
if (!key.startsWith('__loose__')) continue;
if (removed.has(key)) continue;
const member = block.members[0];
const spouse = spousePairs.get(member.id);
if (!spouse) continue;
const spouseLookup = memberLookup.get(spouse);
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
if (spouseLookup.key === key) continue;
if (!spouseLookup.key.startsWith('__loose__')) continue;
const otherBlock = blocksByKey.get(spouseLookup.key)!;
block.members.push(...otherBlock.members);
removed.add(spouseLookup.key);
}
for (const key of removed) blocksByKey.delete(key);
// Step 4: centre each block on its anchor (parented members) and pack.
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
let cursorRight = -Infinity;
for (const block of ordered) {
const n = block.members.length;
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
const anchorIndices: number[] = [];
for (let i = 0; i < n; i++) {
if (block.members[i].parented) anchorIndices.push(i);
}
const avgAnchorIdx =
anchorIndices.length > 0
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
: (n - 1) / 2;
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
for (let i = 0; i < n; i++) {
positions.set(block.members[i].id, {
x: groupLeft + i * (NODE_W + COL_GAP),
y
});
}
cursorRight = groupLeft + groupWidth;
}
}
// Bounding box around the actual content, then expanded to MIN dimensions
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
// is centered on the content.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of positions.values()) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x + NODE_W);
maxY = Math.max(maxY, p.y + NODE_H);
}
if (positions.size === 0) {
minX = 0;
minY = 0;
maxX = 0;
maxY = 0;
}
const contentW = maxX - minX;
const contentH = maxY - minY;
const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
const viewX = minX + contentW / 2 - viewW / 2;
const viewY = minY + contentH / 2 - viewH / 2;
return { positions, generations, viewX, viewY, viewW, viewH };
}
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
const arr = map.get(key);
if (arr) arr.push(value);
else map.set(key, [value]);
}
function nodeCenter(id: string): { x: number; y: number } | null {
const p = layout.positions.get(id);
if (!p) return null;
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
}
let focusedId = $state<string | null>(null);
function handleNodeKey(event: KeyboardEvent, id: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(id);
}
}
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
function pairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
type ParentLinks = {
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
// + per-child vertical pattern in the SVG.
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
// One entry per remaining parent → child edge (single parents, or the
// "second" parent edge when only one parent is in the spouse pair).
single: { key: string; parentId: string; childId: string }[];
};
const parentLinks = $derived.by<ParentLinks>(() => {
const spousePairs = new Set<string>();
for (const e of spouseEdges) {
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
}
const childToParents = new Map<string, string[]>();
for (const e of parentEdges) {
const list = childToParents.get(e.relatedPersonId) ?? [];
list.push(e.personId);
childToParents.set(e.relatedPersonId, list);
}
const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
const single: ParentLinks['single'] = [];
for (const [childId, parents] of childToParents) {
const consumed = new Set<string>();
for (let i = 0; i < parents.length; i++) {
if (consumed.has(parents[i])) continue;
for (let j = i + 1; j < parents.length; j++) {
if (consumed.has(parents[j])) continue;
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
const groupKey = pairKey(parents[i], parents[j]);
const existing = sharedMap.get(groupKey);
if (existing) {
existing.childIds.push(childId);
} else {
sharedMap.set(groupKey, {
parentA: parents[i],
parentB: parents[j],
childIds: [childId]
});
}
consumed.add(parents[i]);
consumed.add(parents[j]);
break;
}
}
}
for (const parentId of parents) {
if (consumed.has(parentId)) continue;
single.push({ key: `${parentId}->${childId}`, parentId, childId });
}
}
const shared: ParentLinks['shared'] = [];
for (const [key, group] of sharedMap) shared.push({ key, ...group });
return { shared, single };
});
</script>
<svg
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
bar, then short verticals from the bar to each child top. -->
{#each parentLinks.shared as group (group.key)}
{@const aCenter = nodeCenter(group.parentA)}
{@const bCenter = nodeCenter(group.parentB)}
{@const childCenters = group.childIds
.map((id) => nodeCenter(id))
.filter((c): c is { x: number; y: number } => c !== null)}
{#if aCenter && bCenter && childCenters.length > 0}
{@const midX = (aCenter.x + bCenter.x) / 2}
{@const parentBottomY = aCenter.y + NODE_H / 2}
{@const childTopY = childCenters[0].y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
{@const xs = childCenters.map((c) => c.x)}
{@const minX = Math.min(midX, ...xs)}
{@const maxX = Math.max(midX, ...xs)}
<line
x1={midX}
y1={parentBottomY}
x2={midX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if minX !== maxX}
<line
x1={minX}
y1={barY}
x2={maxX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{#each childCenters as cc, i (group.childIds[i])}
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/each}
{/if}
{/each}
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
{#each parentLinks.single as link (link.key)}
{@const parentCenter = nodeCenter(link.parentId)}
{@const childCenter = nodeCenter(link.childId)}
{#if parentCenter && childCenter}
{@const parentBottomY = parentCenter.y + NODE_H / 2}
{@const childTopY = childCenter.y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
<line
x1={parentCenter.x}
y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
<line
x1={parentCenter.x}
y1={barY}
x2={childCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{/each}
<!-- Spouse connectors -->
{#each spouseEdges as e (e.id)}
{@const aCenter = nodeCenter(e.personId)}
{@const bCenter = nodeCenter(e.relatedPersonId)}
{#if aCenter && bCenter}
<line
x1={aCenter.x}
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="4.5"
fill="var(--c-primary)"
/>
{/if}
{/each}
<!-- Nodes -->
{#each nodes as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
{@const isSelected = selectedId === node.id}
{@const isFocused = focusedId === node.id}
<g
role="button"
tabindex="0"
aria-label="{node.displayName}{node.birthYear || node.deathYear
? `, ${node.birthYear ?? '?'}${node.deathYear ?? ''}`
: ''}"
aria-expanded={isSelected}
transform="translate({pos.x}, {pos.y})"
onclick={() => onSelect(node.id)}
onkeydown={(e) => handleNodeKey(e, node.id)}
onfocus={() => (focusedId = node.id)}
onblur={() => (focusedId = null)}
class="cursor-pointer focus:outline-none"
>
{#if isFocused}
<rect
x="-3"
y="-3"
width={NODE_W + 6}
height={NODE_H + 6}
rx="6"
fill="none"
stroke="var(--c-focus-ring)"
stroke-width="2"
/>
{/if}
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if isSelected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={isSelected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
{/if}
{/each}
</svg>

View File

@@ -1,349 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
function parseViewBox(svg: SVGElement): [number, number, number, number] {
const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number);
return [parts[0], parts[1], parts[2], parts[3]];
}
function rectsCentroid(svg: SVGElement): { x: number; y: number } {
const rects = Array.from(svg.querySelectorAll('rect'));
let sx = 0;
let sy = 0;
let n = 0;
for (const r of rects) {
const x = parseFloat(r.getAttribute('x') ?? '0');
const y = parseFloat(r.getAttribute('y') ?? '0');
const w = parseFloat(r.getAttribute('width') ?? '0');
const h = parseFloat(r.getAttribute('height') ?? '0');
// Skip the narrow accent stripe.
if (w < 10) continue;
// Each node rect lives inside <g transform="translate(...)">.
const g = r.closest('g[transform]');
const transform = g?.getAttribute('transform') ?? '';
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
const tx = match ? parseFloat(match[1]) : 0;
const ty = match ? parseFloat(match[2]) : 0;
sx += tx + x + w / 2;
sy += ty + y + h / 2;
n++;
}
return { x: sx / n, y: sy / n };
}
describe('StammbaumTree viewBox', () => {
it('uses the minimum size and centers a single node', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const svg = document.querySelector('svg')!;
const [x, y, w, h] = parseViewBox(svg);
// Single 160x56 node fits inside the 1200x800 minimum viewBox.
expect(w).toBe(1200);
expect(h).toBe(800);
// Whatever absolute coordinates the layout uses, the viewBox must
// centre on the rendered content.
const c = rectsCentroid(svg);
expect(x + w / 2).toBeCloseTo(c.x, 1);
expect(y + h / 2).toBeCloseTo(c.y, 1);
});
it('renders only orthogonal segments when two parents share two children', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD_1 = '00000000-0000-0000-0000-00000000000c';
const CHILD_2 = '00000000-0000-0000-0000-00000000000d';
render(StammbaumTree, {
nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
{ id: CHILD_1, displayName: 'Clara', familyMember: true },
{ id: CHILD_2, displayName: 'Hans', familyMember: true }
],
edges: [
{
id: 'sp',
personId: PARENT_A,
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1a',
personId: PARENT_A,
relatedPersonId: CHILD_1,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 'p1b',
personId: PARENT_B,
relatedPersonId: CHILD_1,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 'p2a',
personId: PARENT_A,
relatedPersonId: CHILD_2,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2b',
personId: PARENT_B,
relatedPersonId: CHILD_2,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = Array.from(document.querySelectorAll('svg line'));
// Every parent-child segment must be either vertical (x1==x2) or
// horizontal (y1==y2) — no slanted segments allowed.
const slanted = lines.filter(
(l) =>
l.getAttribute('x1') !== l.getAttribute('x2') &&
l.getAttribute('y1') !== l.getAttribute('y2')
);
expect(slanted).toHaveLength(0);
// Sibling bar must exist and span the children: at least one
// horizontal line whose x1 != x2 and y1 == y2.
const horizontalBars = lines.filter(
(l) =>
l.getAttribute('y1') === l.getAttribute('y2') &&
l.getAttribute('x1') !== l.getAttribute('x2')
);
expect(horizontalBars.length).toBeGreaterThanOrEqual(1);
});
it('positions a single child at the midpoint of its two parents (vertical drop)', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD = '00000000-0000-0000-0000-00000000000c';
render(StammbaumTree, {
nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
{ id: CHILD, displayName: 'Hans', familyMember: true }
],
edges: [
{
id: 'sp',
personId: PARENT_A,
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1',
personId: PARENT_A,
relatedPersonId: CHILD,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2',
personId: PARENT_B,
relatedPersonId: CHILD,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = Array.from(document.querySelectorAll('svg line'));
// No slanted segments. With one child, no horizontal sibling bar
// is needed because midX == child.center.x.
const slanted = lines.filter(
(l) =>
l.getAttribute('x1') !== l.getAttribute('x2') &&
l.getAttribute('y1') !== l.getAttribute('y2')
);
expect(slanted).toHaveLength(0);
});
it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => {
// Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1).
// Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have
// child Lili. Hilde must sit next to Hans, and Lili must be on a
// row below Hans/Hilde — not on the same row.
const WALTER = '00000000-0000-0000-0000-000000000001';
const EUGENIE = '00000000-0000-0000-0000-000000000002';
const HANS = '00000000-0000-0000-0000-000000000003';
const CLARA = '00000000-0000-0000-0000-000000000004';
const HILDE = '00000000-0000-0000-0000-000000000005';
const LILI = '00000000-0000-0000-0000-000000000006';
render(StammbaumTree, {
nodes: [
{ id: WALTER, displayName: 'Walter', familyMember: true },
{ id: EUGENIE, displayName: 'Eugenie', familyMember: true },
{ id: HANS, displayName: 'Hans', familyMember: true },
{ id: CLARA, displayName: 'Clara', familyMember: true },
{ id: HILDE, displayName: 'Hilde', familyMember: true },
{ id: LILI, displayName: 'Lili', familyMember: true }
],
edges: [
{
id: 's1',
personId: WALTER,
relatedPersonId: EUGENIE,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1',
personId: WALTER,
relatedPersonId: HANS,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2',
personId: EUGENIE,
relatedPersonId: HANS,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p3',
personId: WALTER,
relatedPersonId: CLARA,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 'p4',
personId: EUGENIE,
relatedPersonId: CLARA,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 's2',
personId: HANS,
relatedPersonId: HILDE,
personDisplayName: 'Hans',
relatedPersonDisplayName: 'Hilde',
relationType: 'SPOUSE_OF'
},
{
id: 'p5',
personId: HANS,
relatedPersonId: LILI,
personDisplayName: 'Hans',
relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF'
},
{
id: 'p6',
personId: HILDE,
relatedPersonId: LILI,
personDisplayName: 'Hilde',
relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const ys = new Map<string, number>();
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
const aria = g.getAttribute('aria-label') ?? '';
const transform = g.getAttribute('transform') ?? '';
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
if (!match) continue;
ys.set(aria.split(',')[0], parseFloat(match[2]));
}
// Lili must be on a deeper row than Hans / Hilde.
expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!);
expect(ys.get('Hans')).toEqual(ys.get('Hilde'));
// Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP).
const xs = new Map<string, number>();
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
const aria = g.getAttribute('aria-label') ?? '';
const transform = g.getAttribute('transform') ?? '';
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
if (!match) continue;
xs.set(aria.split(',')[0], parseFloat(match[1]));
}
expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40);
// All parent-child segments must be orthogonal.
const lines = Array.from(document.querySelectorAll('svg line'));
const slanted = lines.filter(
(l) =>
l.getAttribute('x1') !== l.getAttribute('x2') &&
l.getAttribute('y1') !== l.getAttribute('y2')
);
expect(slanted).toHaveLength(0);
});
it('centers two spouse nodes within the minimum viewBox', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [
{
id: 'e1',
personId: ID_A,
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const svg = document.querySelector('svg')!;
const [x, y, w, h] = parseViewBox(svg);
expect(w).toBe(1200);
expect(h).toBe(800);
const c = rectsCentroid(svg);
expect(x + w / 2).toBeCloseTo(c.x, 1);
expect(y + h / 2).toBeCloseTo(c.y, 1);
});
});

View File

@@ -2,8 +2,6 @@
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import CommentThread from './CommentThread.svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { PersonMention } from '$lib/types';
const { confirm } = getConfirmService();
@@ -14,14 +12,13 @@ type Props = {
documentId: string;
blockNumber: number;
text: string;
mentionedPersons: PersonMention[];
label: string | null;
active: boolean;
reviewed: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onTextChange: (text: string) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
@@ -38,7 +35,6 @@ let {
documentId,
blockNumber,
text,
mentionedPersons,
label = null,
active,
reviewed,
@@ -58,10 +54,10 @@ let {
}: Props = $props();
let localText = $state(text);
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
let commentOpen = $state(false);
let commentCount = $state(0);
let selectedQuote = $state<string | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
const hasComments = $derived(commentCount > 0);
@@ -70,7 +66,6 @@ let prevBlockId = $state(blockId);
$effect(() => {
if (blockId !== prevBlockId) {
localText = text;
localMentions = [...mentionedPersons];
prevBlockId = blockId;
}
});
@@ -79,19 +74,29 @@ let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
// Single source of truth for the editor's textarea — stored on attach so
// we can read selection bounds for quote selection without re-querying the DOM.
let textareaEl: HTMLTextAreaElement | null = null;
function captureTextarea(node: HTMLTextAreaElement) {
function autoresize(node: HTMLTextAreaElement) {
textareaEl = node;
return () => {
textareaEl = null;
function resize() {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
}
resize();
return {
update() {
resize();
},
destroy() {
textareaEl = null;
}
};
}
function emitChange() {
onTextChange(localText, localMentions);
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement;
localText = target.value;
onTextChange(target.value);
}
async function handleDelete() {
@@ -176,24 +181,17 @@ function handleTextareaMouseUp() {
{/if}
</div>
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
<div onmouseup={handleTextareaMouseUp} role="presentation">
<PersonMentionEditor
bind:value={() => localText,
(v) => {
localText = v;
emitChange();
}}
bind:mentionedPersons={() => localMentions,
(next) => {
localMentions = next;
emitChange();
}}
placeholder={m.transcription_block_placeholder()}
onfocus={onFocus}
captureTextarea={captureTextarea}
/>
</div>
<!-- Textarea -->
<textarea
use:autoresize={localText}
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
placeholder={m.transcription_block_placeholder()}
rows={1}
value={localText}
oninput={handleInput}
onfocus={onFocus}
onmouseup={handleTextareaMouseUp}
></textarea>
{#if selectedQuote}
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>

View File

@@ -1,24 +1,21 @@
<script lang="ts">
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import type { PersonMention } from '$lib/types';
type BlockProps = {
blockId: string;
documentId: string;
blockNumber: number;
text: string;
mentionedPersons?: PersonMention[];
label: string | null;
active: boolean;
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onTextChange: (text: string) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
onReviewToggle?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
@@ -27,22 +24,13 @@ type BlockProps = {
let {
onServiceReady,
mentionedPersons = [],
reviewed = false,
onReviewToggle = () => {},
...blockProps
}: BlockProps & {
onServiceReady: (s: ConfirmService) => void;
reviewed?: boolean;
} = $props();
const service = provideConfirmService();
onServiceReady(service);
</script>
<TranscriptionBlock
{...blockProps}
mentionedPersons={mentionedPersons}
reviewed={reviewed}
onReviewToggle={onReviewToggle}
/>
<TranscriptionBlock {...blockProps} />

View File

@@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte';
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
import type { TranscriptionBlockData } from '$lib/types';
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
@@ -16,7 +16,7 @@ type Props = {
storedScriptType?: string;
canRunOcr?: boolean;
onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>;
onMarkAllReviewed?: () => Promise<void>;
@@ -245,19 +245,16 @@ async function handleLabelToggle(label: string) {
documentId={documentId}
blockNumber={i + 1}
text={block.text}
mentionedPersons={block.mentionedPersons ?? []}
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
saveState={autoSave.getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
onTextChange={(text, mentions) =>
autoSave.handleTextChange(block.id, text, mentions)}
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() =>
autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])}
onRetry={() => autoSave.handleRetry(block.id, block.text)}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}

View File

@@ -15,8 +15,7 @@ const block1 = {
sortOrder: 0,
version: 0,
source: 'MANUAL' as const,
reviewed: false,
mentionedPersons: []
reviewed: false
};
const block2 = {
id: 'b2',
@@ -27,8 +26,7 @@ const block2 = {
sortOrder: 1,
version: 0,
source: 'OCR' as const,
reviewed: true,
mentionedPersons: []
reviewed: true
};
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
@@ -143,28 +141,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []);
vi.useRealTimers();
});
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
const blockWithMention = {
...block1,
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
};
renderView({ blocks: [blockWithMention], onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('Hallo @Auguste Raddatz');
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
]);
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile');
vi.useRealTimers();
});
@@ -188,7 +165,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
// Only one save with the final value
expect(onSaveBlock).toHaveBeenCalledTimes(1);
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second');
vi.useRealTimers();
});
});
@@ -243,7 +220,7 @@ describe('TranscriptionEditView — flush on blur', () => {
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text');
vi.useRealTimers();
});
});

View File

@@ -1,16 +1,6 @@
<script lang="ts">
import type { TranscriptionBlockData } from '$lib/types';
import type { components } from '$lib/generated/api';
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
import { renderTranscriptionBody } from '$lib/utils/mention';
import PersonHoverCard, { type LoadState } from './PersonHoverCard.svelte';
import { goto } from '$app/navigation';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type HoverData = { person: Person; relationships: RelationshipDTO[] };
interface Props {
blocks: TranscriptionBlockData[];
@@ -21,172 +11,9 @@ interface Props {
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
// Per-page in-memory cache: a sweep across 20 mentions of the same person
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> shape
// lets simultaneous hovers share the same in-flight fetch.
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
const deletedPersonIds = new SvelteSet<string>();
let activeCard: {
personId: string;
cardId: string;
state: LoadState;
position: { top: number; left: number };
} | null = $state(null);
const CARD_WIDTH = 320;
const CARD_HEIGHT = 180;
const CARD_GAP = 6;
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
// as <em data-marker> tags; text segments run through HTML-escaping + mention
// substitution. The two are concatenated to preserve marker boundaries — markers
// never end up nested inside an anchor (Felix #5324 B19b).
function renderBlockHtml(block: TranscriptionBlockData): string {
return splitByMarkers(block.text)
.map((segment) => {
if (segment.type === 'marker') {
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>`;
}
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
})
.join('');
}
function fetchHoverData(personId: string): Promise<HoverData | null> {
let cached = hoverCache.get(personId);
if (cached) return cached;
cached = (async () => {
const personRes = await fetch(`/api/persons/${personId}`);
if (personRes.status === 404) return null;
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
const person = (await personRes.json()) as Person;
const relRes = await fetch(`/api/persons/${personId}/relationships`);
const relationships: RelationshipDTO[] = relRes.ok
? ((await relRes.json()) as RelationshipDTO[])
: [];
return { person, relationships };
})();
hoverCache.set(personId, cached);
return cached;
}
function computeCardPosition(rect: DOMRect): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = rect.bottom + CARD_GAP;
let left = rect.left;
// Flip up if the card would overflow the bottom edge OR the mention sits in
// the bottom 30% of the viewport (Leonie #5329).
if (vh - rect.bottom < CARD_HEIGHT + CARD_GAP || rect.top > vh * 0.7) {
top = rect.top - CARD_HEIGHT - CARD_GAP;
}
// Flip left if <300px from the right edge.
if (vw - rect.left < 300) {
left = rect.right - CARD_WIDTH;
}
return {
top: Math.max(0, top + window.scrollY),
left: Math.max(0, left + window.scrollX)
};
}
async function handleMentionEnter(event: Event) {
const link = event.target as HTMLAnchorElement;
const personId = link.dataset.personId;
if (!personId) return;
if (deletedPersonIds.has(personId)) return;
const cardId = `person-hover-card-${personId}`;
link.setAttribute('aria-describedby', cardId);
const rect = link.getBoundingClientRect();
const position = computeCardPosition(rect);
activeCard = { personId, cardId, position, state: { status: 'loading' } };
try {
const data = await fetchHoverData(personId);
// Bail if a different mention is now active
if (!activeCard || activeCard.personId !== personId) return;
if (data === null) {
deletedPersonIds.add(personId);
link.setAttribute('data-person-deleted', 'true');
activeCard = null;
return;
}
activeCard = {
personId,
cardId,
position,
state: { status: 'loaded', person: data.person, relationships: data.relationships }
};
} catch {
if (!activeCard || activeCard.personId !== personId) return;
activeCard = { personId, cardId, position, state: { status: 'error' } };
}
}
function handleMentionLeave(event: Event) {
const link = event.target as HTMLAnchorElement;
link.removeAttribute('aria-describedby');
activeCard = null;
}
async function handleMentionClick(event: MouseEvent) {
const link = event.target as HTMLAnchorElement;
const personId = link.dataset.personId;
if (!personId) return;
if (deletedPersonIds.has(personId)) {
event.preventDefault();
return;
}
event.preventDefault();
await goto(`/persons/${personId}`);
}
// Attach delegated event listeners on each rendered block. Using {@html ...}
// for the body means we cannot bind events declaratively to the injected
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
function attachMentionHandlers(node: HTMLElement) {
function onEnter(e: Event) {
const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionEnter(e);
}
function onLeave(e: Event) {
const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionLeave(e);
}
function onClick(e: MouseEvent) {
const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionClick(e);
}
// mouseenter does not bubble — capture it.
node.addEventListener('mouseenter', onEnter, true);
node.addEventListener('mouseleave', onLeave, true);
node.addEventListener('click', onClick);
return {
destroy() {
node.removeEventListener('mouseenter', onEnter, true);
node.removeEventListener('mouseleave', onLeave, true);
node.removeEventListener('click', onClick);
}
};
}
</script>
<article class="px-6 py-8" use:attachMentionHandlers>
<article class="px-6 py-8">
{#each sorted as block (block.id)}
<div
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
@@ -195,25 +22,19 @@ function attachMentionHandlers(node: HTMLElement) {
onclick={() => onParagraphClick(block.annotationId)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
}}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }}
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
{@html renderBlockHtml(block)}
{#each splitByMarkers(block.text) as segment, i (i)}
{#if segment.type === 'marker'}
<em data-marker class="text-ink-2 italic">{segment.text}</em>
{:else}
{segment.text}
{/if}
{/each}
</div>
{/each}
</article>
{#if activeCard}
<PersonHoverCard
personId={activeCard.personId}
cardId={activeCard.cardId}
position={activeCard.position}
state={activeCard.state}
/>
{/if}
<style>
@keyframes flash {
0% {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionReadView from './TranscriptionReadView.svelte';
import type { TranscriptionBlockData } from '$lib/types';
@@ -12,10 +12,7 @@ const blocks: TranscriptionBlockData[] = [
text: 'First paragraph text.',
label: null,
sortOrder: 1,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
version: 1
},
{
id: 'b2',
@@ -24,10 +21,7 @@ const blocks: TranscriptionBlockData[] = [
text: 'Second paragraph text.',
label: null,
sortOrder: 2,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
version: 1
}
];
@@ -55,10 +49,7 @@ describe('TranscriptionReadView', () => {
text: 'Text before [unleserlich] text after',
label: null,
sortOrder: 1,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
version: 1
}
],
onParagraphClick: () => {}
@@ -80,10 +71,7 @@ describe('TranscriptionReadView', () => {
text: 'Some [...] text',
label: null,
sortOrder: 1,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
version: 1
}
],
onParagraphClick: () => {}
@@ -152,241 +140,3 @@ describe('TranscriptionReadView', () => {
expect(paragraphs.length).toBe(0);
});
});
describe('TranscriptionReadView — person-mention rendering', () => {
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
const mentionBlock: TranscriptionBlockData = {
id: 'b1',
annotationId: 'ann-1',
documentId: 'doc-1',
text: 'Brief an @Auguste Raddatz vom Mai',
label: null,
sortOrder: 1,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
};
beforeEach(() => {
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
// Tests that need loaded data stub fetch themselves.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('renders a person mention as an anchor link with the person URL', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
expect(link).not.toBeNull();
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
expect(link.textContent).toBe('Auguste Raddatz');
});
it('strips the @ trigger from the rendered link text (read mode)', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const block = document.querySelector('[data-block-id="b1"]')!;
expect(block.textContent).not.toContain('@Auguste Raddatz');
expect(block.textContent).toContain('Auguste Raddatz');
});
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
const block: TranscriptionBlockData = {
...mentionBlock,
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
};
render(TranscriptionReadView, {
blocks: [block],
onParagraphClick: () => {}
});
// Mention rendered as an anchor
const link = document.querySelector('a.person-mention')!;
expect(link).not.toBeNull();
expect(link.textContent).toBe('Auguste Raddatz');
// Marker rendered as <em data-marker>
const marker = document.querySelector('[data-marker]')!;
expect(marker).not.toBeNull();
expect(marker.textContent).toBe('[unleserlich]');
// Marker text is NOT inside the anchor — they are siblings, not nested
expect(link.contains(marker)).toBe(false);
// No double-escape — text content reads cleanly
const blockEl = document.querySelector('[data-block-id="b1"]')!;
expect(blockEl.textContent).not.toContain('&amp;');
expect(blockEl.textContent).not.toContain('&lt;');
});
it('does not render mention link for plain text without the @ trigger', async () => {
const plain: TranscriptionBlockData = {
...mentionBlock,
text: 'Auguste Raddatz war hier',
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
};
render(TranscriptionReadView, {
blocks: [plain],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention');
expect(link).toBeNull();
});
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
const xss: TranscriptionBlockData = {
...mentionBlock,
text: '<img src=x onerror=alert(1)>',
mentionedPersons: []
};
render(TranscriptionReadView, {
blocks: [xss],
onParagraphClick: () => {}
});
// No raw <img> tag in DOM
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
// The escaped text is visible
const block = document.querySelector('[data-block-id="b1"]')!;
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 404,
ok: false,
json: vi.fn()
});
vi.stubGlobal('fetch', fetchMock);
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
const personFetches = fetchMock.mock.calls.filter((c) =>
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
);
expect(personFetches.length).toBeGreaterThanOrEqual(1);
});
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 404,
ok: false,
json: vi.fn()
});
vi.stubGlobal('fetch', fetchMock);
// Two blocks both mention the same person
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
render(TranscriptionReadView, {
blocks: [mentionBlock, block2],
onParagraphClick: () => {}
});
const links = document.querySelectorAll('a.person-mention');
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
// Plus a re-hover on the first
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
const personFetches = fetchMock.mock.calls.filter(
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
);
expect(personFetches.length).toBe(1);
});
it('mounts the hover card on mouseenter when the fetch loads', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((url: string) => {
if (url.endsWith('/relationships')) {
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
}
return Promise.resolve({
status: 200,
ok: true,
json: () =>
Promise.resolve({
id: PERSON_ID,
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthYear: 1882,
deathYear: 1944
})
});
})
);
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).not.toBeNull();
});
it('unmounts the hover card on mouseleave', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 5));
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await new Promise((r) => setTimeout(r, 5));
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
});
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
// 404 → no card mounted
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
const stillLink = document.querySelector('a.person-mention')!;
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
});
});

View File

@@ -8,7 +8,6 @@ export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'ALIAS_NOT_FOUND'
| 'INVALID_PERSON_TYPE'
| 'PERSON_RENAME_CONFLICT'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
@@ -39,9 +38,6 @@ export type ErrorCode =
| 'TAG_NOT_FOUND'
| 'TAG_MERGE_SELF'
| 'TAG_MERGE_INVALID_TARGET'
| 'RELATIONSHIP_NOT_FOUND'
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -80,8 +76,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_alias_not_found();
case 'INVALID_PERSON_TYPE':
return m.error_invalid_person_type();
case 'PERSON_RENAME_CONFLICT':
return m.error_person_rename_conflict();
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':
@@ -142,12 +136,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_tag_merge_self();
case 'TAG_MERGE_INVALID_TARGET':
return m.error_tag_merge_invalid_target();
case 'RELATIONSHIP_NOT_FOUND':
return m.error_relationship_not_found();
case 'CIRCULAR_RELATIONSHIP':
return m.error_circular_relationship();
case 'DUPLICATE_RELATIONSHIP':
return m.error_duplicate_relationship();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':

View File

@@ -132,22 +132,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/transcription-blocks/review-all": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["markAllBlocksReviewed"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/transcription-blocks/reorder": {
parameters: {
query?: never;
@@ -228,22 +212,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/relationships": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRelationships"];
put?: never;
post: operations["addRelationship"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/merge": {
parameters: {
query?: never;
@@ -644,22 +612,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/family-member": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["patchFamilyMember"];
trace?: never;
};
"/api/notifications/{id}/read": {
parameters: {
query?: never;
@@ -900,22 +852,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/inferred-relationships": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getInferredRelationships"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/documents": {
parameters: {
query?: never;
@@ -948,22 +884,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{aId}/relationship-to/{bId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRelationshipBetween"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/ocr/training-info": {
parameters: {
query?: never;
@@ -1124,22 +1044,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/network": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getNetwork"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{id}/versions": {
parameters: {
query?: never;
@@ -1428,22 +1332,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/relationships/{relId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteRelationship"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/aliases/{aliasId}": {
parameters: {
query?: never;
@@ -1542,8 +1430,6 @@ export interface components {
color?: string;
};
PersonUpdateDTO: {
/** @enum {string} */
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
title?: string;
firstName?: string;
lastName?: string;
@@ -1568,7 +1454,6 @@ export interface components {
birthYear?: number;
/** Format: int32 */
deathYear?: number;
familyMember: boolean;
readonly displayName: string;
};
DocumentUpdateDTO: {
@@ -1577,8 +1462,6 @@ export interface components {
documentDate?: string;
location?: string;
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
transcription?: string;
summary?: string;
/** Format: uuid */
@@ -1627,15 +1510,9 @@ export interface components {
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
thumbnailUrl?: string;
};
PersonMention: {
/** Format: uuid */
personId: string;
displayName: string;
};
UpdateTranscriptionBlockDTO: {
text?: string;
label?: string;
mentionedPersons?: components["schemas"]["PersonMention"][];
};
TranscriptionBlock: {
/** Format: uuid */
@@ -1645,7 +1522,6 @@ export interface components {
/** Format: uuid */
documentId: string;
text?: string;
mentionedPersons: components["schemas"]["PersonMention"][];
label?: string;
/** Format: int32 */
sortOrder: number;
@@ -1685,42 +1561,6 @@ export interface components {
/** Format: uuid */
targetId: string;
};
CreateRelationshipRequest: {
/** Format: uuid */
relatedPersonId: string;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
RelationshipDTO: {
/** Format: uuid */
id: string;
/** Format: uuid */
personId: string;
/** Format: uuid */
relatedPersonId: string;
personDisplayName: string;
/** Format: int32 */
personBirthYear?: number;
/** Format: int32 */
personDeathYear?: number;
relatedPersonDisplayName: string;
/** Format: int32 */
relatedPersonBirthYear?: number;
/** Format: int32 */
relatedPersonDeathYear?: number;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
PersonNameAliasDTO: {
lastName: string;
firstName?: string;
@@ -1820,7 +1660,6 @@ export interface components {
height?: number;
text?: string;
label?: string;
mentionedPersons?: components["schemas"]["PersonMention"][];
};
CreateCommentDTO: {
content?: string;
@@ -1969,9 +1808,6 @@ export interface components {
/** Format: int32 */
count: number;
};
FamilyMemberPatchDTO: {
familyMember?: boolean;
};
NotificationDTO: {
/** Format: uuid */
id: string;
@@ -2076,38 +1912,15 @@ export interface components {
displayName?: string;
firstName?: string;
lastName?: string;
personType?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
familyMember?: boolean;
notes?: string;
/** Format: int64 */
documentCount?: number;
alias?: string;
};
InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"];
label: string;
/** Format: int32 */
hops: number;
};
PersonNodeDTO: {
/** Format: uuid */
id: string;
displayName: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
familyMember: boolean;
};
InferredRelationshipDTO: {
labelFromA: string;
labelFromB: string;
/** Format: int32 */
hops: number;
alias?: string;
notes?: string;
personType?: string;
};
SenderModel: {
/** Format: uuid */
@@ -2208,10 +2021,6 @@ export interface components {
empty?: boolean;
unsorted?: boolean;
};
NetworkDTO: {
nodes: components["schemas"]["PersonNodeDTO"][];
edges: components["schemas"]["RelationshipDTO"][];
};
DocumentVersionSummary: {
/** Format: uuid */
id: string;
@@ -2322,7 +2131,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -2772,28 +2581,6 @@ export interface operations {
};
};
};
markAllBlocksReviewed: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TranscriptionBlock"][];
};
};
};
};
reorderBlocks: {
parameters: {
query?: never;
@@ -2958,54 +2745,6 @@ export interface operations {
};
};
};
getRelationships: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RelationshipDTO"][];
};
};
};
};
addRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateRelationshipRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RelationshipDTO"];
};
};
};
};
mergePerson: {
parameters: {
query?: never;
@@ -3752,32 +3491,6 @@ export interface operations {
};
};
};
patchFamilyMember: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["FamilyMemberPatchDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
markOneRead: {
parameters: {
query?: never;
@@ -4176,28 +3889,6 @@ export interface operations {
};
};
};
getInferredRelationships: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["InferredRelationshipWithPersonDTO"][];
};
};
};
};
getPersonDocuments: {
parameters: {
query?: never;
@@ -4244,29 +3935,6 @@ export interface operations {
};
};
};
getRelationshipBetween: {
parameters: {
query?: never;
header?: never;
path: {
aId: string;
bId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["InferredRelationshipDTO"];
};
};
};
};
getTrainingInfo: {
parameters: {
query?: never;
@@ -4482,26 +4150,6 @@ export interface operations {
};
};
};
getNetwork: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["NetworkDTO"];
};
};
};
};
getVersions: {
parameters: {
query?: never;
@@ -4822,7 +4470,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED")[];
};
header?: never;
path?: never;
@@ -4923,27 +4571,6 @@ export interface operations {
};
};
};
deleteRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeAlias: {
parameters: {
query?: never;

View File

@@ -1,10 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { PersonMention } from '$lib/types';
const mockSaveFn =
vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>>();
const NO_MENTIONS: PersonMention[] = [];
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
@@ -26,25 +22,25 @@ describe('createBlockAutoSave', () => {
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
as.handleTextChange('block-1', 'text 1');
as.handleTextChange('block-1', 'text 2');
as.handleTextChange('block-1', 'text 3');
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(1);
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
});
it('handles concurrent blocks independently', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
as.handleTextChange('block-1', 'hello');
as.handleTextChange('block-2', 'world');
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
});
it('sets save state to saving then saved on success', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.handleTextChange('block-1', 'text');
vi.advanceTimersByTime(1500);
expect(as.getSaveState('block-1')).toBe('saving');
await Promise.resolve();
@@ -54,7 +50,7 @@ describe('createBlockAutoSave', () => {
it('sets save state to error on save failure', async () => {
mockSaveFn.mockRejectedValue(new Error('save failed'));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.handleTextChange('block-1', 'text');
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
});
@@ -63,49 +59,24 @@ describe('createBlockAutoSave', () => {
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
mockSaveFn.mockResolvedValueOnce(undefined);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'original', NO_MENTIONS);
as.handleTextChange('block-1', 'original');
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
await as.handleRetry('block-1', 'original', NO_MENTIONS);
await as.handleRetry('block-1', 'original');
expect(mockSaveFn).toHaveBeenCalledTimes(2);
expect(as.getSaveState('block-1')).toBe('saved');
});
it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
// Hold the second saveFn so we can observe the saving→saved transition
// (Tester #5506 §5).
let resolveSecond!: () => void;
mockSaveFn.mockRejectedValueOnce(new Error('boom'));
mockSaveFn.mockReturnValueOnce(new Promise<void>((r) => (resolveSecond = r)));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
// Retry without re-passing the data — the hook resends the preserved payload.
const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
// Yield once so executeSave runs synchronously up to the saveFn await.
await Promise.resolve();
expect(as.getSaveState('block-1')).toBe('saving');
expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
resolveSecond();
await retryPromise;
expect(as.getSaveState('block-1')).toBe('saved');
});
it('clearBlock removes all state for a block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.handleTextChange('block-1', 'text');
as.clearBlock('block-1');
expect(as.getSaveState('block-1')).toBe('idle');
});
it('destroy clears all pending timers so no save occurs', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.handleTextChange('block-1', 'text');
as.destroy();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
@@ -130,8 +101,8 @@ describe('flushOnUnload', () => {
it('sends a PUT request with keepalive:true for each pending block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
as.handleTextChange('block-1', 'hello');
as.handleTextChange('block-2', 'world');
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -140,7 +111,7 @@ describe('flushOnUnload', () => {
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
body: JSON.stringify({ text: 'hello' })
})
);
expect(mockFetch).toHaveBeenCalledWith(
@@ -148,7 +119,7 @@ describe('flushOnUnload', () => {
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'world', mentionedPersons: [] })
body: JSON.stringify({ text: 'world' })
})
);
});
@@ -156,7 +127,7 @@ describe('flushOnUnload', () => {
it('does not call navigator.sendBeacon', () => {
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.handleTextChange('block-1', 'text');
as.flushOnUnload();
expect(sendBeaconSpy).not.toHaveBeenCalled();
@@ -171,7 +142,7 @@ describe('flushOnUnload', () => {
it('cancels the debounce timer so saveFn is not also called', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.handleTextChange('block-1', 'text');
as.flushOnUnload();
await vi.advanceTimersByTimeAsync(2000);
@@ -180,26 +151,13 @@ describe('flushOnUnload', () => {
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.handleTextChange('block-1', 'text');
await vi.advanceTimersByTimeAsync(1500);
// debounce has fired; pendingTexts should be empty now
mockFetch.mockClear();
as.flushOnUnload();
expect(mockFetch).not.toHaveBeenCalled();
});
it('flushes the pending mentionedPersons sidecar alongside text', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({
body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
})
);
});
});

View File

@@ -12,8 +12,7 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
sortOrder,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
reviewed: false
};
}

View File

@@ -1,10 +1,9 @@
import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/types';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Options = {
saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
saveFn: (blockId: string, text: string) => Promise<void>;
documentId: string;
};
@@ -12,7 +11,6 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
const saveStates = new SvelteMap<string, SaveState>();
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
const pendingTexts = new SvelteMap<string, string>();
const pendingMentions = new SvelteMap<string, PersonMention[]>();
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
function getSaveState(blockId: string): SaveState {
@@ -27,19 +25,14 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
const mentions = pendingMentions.get(blockId) ?? [];
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
setSaveState(blockId, 'saving');
try {
await saveFn(blockId, text, mentions);
await saveFn(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
// Preserve in-flight payload so the user can retry without re-typing.
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
setSaveState(blockId, 'error');
}
}
@@ -76,13 +69,8 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
}
}
function handleTextChange(
blockId: string,
text: string,
mentionedPersons: PersonMention[]
): void {
function handleTextChange(blockId: string, text: string): void {
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentionedPersons);
scheduleDebounce(blockId);
}
@@ -93,37 +81,29 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
}
}
async function handleRetry(
blockId: string,
currentText: string,
currentMentions: PersonMention[]
): Promise<void> {
const text = pendingTexts.get(blockId) ?? currentText;
const mentions = pendingMentions.get(blockId) ?? currentMentions;
async function handleRetry(blockId: string, currentText: string): Promise<void> {
const pending = pendingTexts.get(blockId);
const text = pending ?? currentText;
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
await executeSave(blockId);
}
function clearBlock(blockId: string): void {
clearDebounce(blockId);
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
saveStates.delete(blockId);
}
function flushOnUnload(): void {
for (const [blockId, text] of pendingTexts) {
const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId);
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons: mentions }),
body: JSON.stringify({ text }),
keepalive: true
});
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
}
}

View File

@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest';
import { m } from '$lib/paraglide/messages.js';
import { chipLabel, otherName } from './relationshipLabels';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const ALICE_ID = 'alice-uuid';
const BOB_ID = 'bob-uuid';
function makeRel(
relationType: RelationshipDTO['relationType'],
override: Partial<RelationshipDTO> = {}
): RelationshipDTO {
return {
id: 'rel-1',
personId: ALICE_ID,
relatedPersonId: BOB_ID,
personDisplayName: 'Alice',
relatedPersonDisplayName: 'Bob',
relationType,
...override
};
}
describe('chipLabel', () => {
it('returns parent_of when perspective is the subject of PARENT_OF', () => {
const rel = makeRel('PARENT_OF');
expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_parent_of());
});
it('returns child_of when perspective is the object of PARENT_OF', () => {
const rel = makeRel('PARENT_OF');
expect(chipLabel(rel, BOB_ID)).toBe(m.relation_child_of());
});
it('returns spouse_of for SPOUSE_OF regardless of perspective', () => {
const rel = makeRel('SPOUSE_OF');
expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_spouse_of());
expect(chipLabel(rel, BOB_ID)).toBe(m.relation_spouse_of());
});
it('returns sibling_of for SIBLING_OF', () => {
expect(chipLabel(makeRel('SIBLING_OF'), ALICE_ID)).toBe(m.relation_sibling_of());
});
it('returns friend for FRIEND', () => {
expect(chipLabel(makeRel('FRIEND'), ALICE_ID)).toBe(m.relation_friend());
});
it('returns other for OTHER', () => {
expect(chipLabel(makeRel('OTHER'), ALICE_ID)).toBe(m.relation_other());
});
});
describe('otherName', () => {
it('returns relatedPersonDisplayName when perspective is the subject', () => {
const rel = makeRel('PARENT_OF');
expect(otherName(rel, ALICE_ID)).toBe('Bob');
});
it('returns personDisplayName when perspective is the object', () => {
const rel = makeRel('PARENT_OF');
expect(otherName(rel, BOB_ID)).toBe('Alice');
});
});

View File

@@ -1,77 +0,0 @@
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
export function chipLabel(rel: RelationshipDTO, perspectivePersonId: string): string {
const viewpointIsSubject = rel.personId === perspectivePersonId;
switch (rel.relationType) {
case 'PARENT_OF':
return viewpointIsSubject ? m.relation_parent_of() : m.relation_child_of();
case 'SPOUSE_OF':
return m.relation_spouse_of();
case 'SIBLING_OF':
return m.relation_sibling_of();
case 'FRIEND':
return m.relation_friend();
case 'COLLEAGUE':
return m.relation_colleague();
case 'EMPLOYER':
return m.relation_employer();
case 'DOCTOR':
return m.relation_doctor();
case 'NEIGHBOR':
return m.relation_neighbor();
default:
return m.relation_other();
}
}
export function otherName(rel: RelationshipDTO, perspectivePersonId: string): string {
return rel.personId === perspectivePersonId
? rel.relatedPersonDisplayName
: rel.personDisplayName;
}
/**
* Maps a backend inferred-label key (parent, uncle_aunt, ...) to its
* localised string. Unknown keys fall back to "distant".
*/
export function inferredRelationshipLabel(key: string): string {
switch (key) {
case 'parent':
return m.relation_inferred_parent();
case 'child':
return m.relation_inferred_child();
case 'spouse':
return m.relation_inferred_spouse();
case 'sibling':
return m.relation_inferred_sibling();
case 'grandparent':
return m.relation_inferred_grandparent();
case 'grandchild':
return m.relation_inferred_grandchild();
case 'great_grandparent':
return m.relation_inferred_great_grandparent();
case 'great_grandchild':
return m.relation_inferred_great_grandchild();
case 'uncle_aunt':
return m.relation_inferred_uncle_aunt();
case 'niece_nephew':
return m.relation_inferred_niece_nephew();
case 'great_uncle_aunt':
return m.relation_inferred_great_uncle_aunt();
case 'great_niece_nephew':
return m.relation_inferred_great_niece_nephew();
case 'inlaw_parent':
return m.relation_inferred_inlaw_parent();
case 'inlaw_child':
return m.relation_inferred_inlaw_child();
case 'sibling_inlaw':
return m.relation_inferred_sibling_inlaw();
case 'cousin_1':
return m.relation_inferred_cousin_1();
default:
return m.relation_inferred_distant();
}
}

View File

@@ -37,11 +37,6 @@ export type Comment = {
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
export type PersonMention = {
personId: string;
displayName: string;
};
export type TranscriptionBlockData = {
id: string;
annotationId: string;
@@ -52,7 +47,6 @@ export type TranscriptionBlockData = {
version: number;
source: 'MANUAL' | 'OCR';
reviewed: boolean;
mentionedPersons: PersonMention[];
updatedAt?: string | null;
};

View File

@@ -1,128 +0,0 @@
import { describe, it, expect } from 'vitest';
import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge';
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
const baseBlock: TranscriptionBlockData = {
id: 'b1',
annotationId: 'a1',
documentId: 'd1',
text: 'old text from server',
label: null,
sortOrder: 0,
version: 7,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
};
describe('mergeBlockOnConflict', () => {
it('keeps the local unsaved text — never overwritten by server text (B12b)', () => {
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, text: 'server-side text' },
localText: 'transcriber unsaved input',
localMentions: []
});
expect(merged.text).toBe('transcriber unsaved input');
});
it('takes server-side displayName for personIds present on both sides (rename win)', () => {
const localMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale: server renamed her
];
const serverMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Augusta Raddatz' } // post-rename
];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
localText: '@Augusta Raddatz',
localMentions
});
expect(merged.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
]);
});
it('keeps local-only mentions added since last save', () => {
const localMentions: PersonMention[] = [
{ personId: 'p-anna', displayName: 'Anna Schmidt' } // typed since last save
];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: [] },
localText: '@Anna Schmidt',
localMentions
});
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-anna',
displayName: 'Anna Schmidt'
});
});
it('returns a union of personIds when local and server diverge', () => {
const localMentions: PersonMention[] = [{ personId: 'p-anna', displayName: 'Anna Schmidt' }];
const serverMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
localText: '@Augusta Raddatz und @Anna Schmidt',
localMentions
});
expect(merged.mentionedPersons).toHaveLength(2);
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-aug',
displayName: 'Augusta Raddatz'
});
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-anna',
displayName: 'Anna Schmidt'
});
});
it('carries server version forward so the next save sends the latest revision', () => {
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, version: 42 },
localText: 'x',
localMentions: []
});
expect(merged.version).toBe(42);
});
it('carries server-only mention array through when local has none', () => {
const merged = mergeBlockOnConflict({
serverBlock: {
...baseBlock,
mentionedPersons: [
{ personId: 'p-aug', displayName: 'Augusta Raddatz' },
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
]
},
localText: 'x',
localMentions: []
});
expect(merged.mentionedPersons).toHaveLength(2);
});
it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => {
const merged = mergeBlockOnConflict({
serverBlock: {
...baseBlock,
sortOrder: 9,
reviewed: true,
updatedAt: '2026-04-29T10:00:00Z'
},
localText: 'x',
localMentions: []
});
expect(merged.sortOrder).toBe(9);
expect(merged.reviewed).toBe(true);
expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z');
});
});
describe('BlockConflictResolvedError', () => {
it('is an Error with code = CONFLICT_RESOLVED', () => {
const err = new BlockConflictResolvedError('block-1');
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('CONFLICT_RESOLVED');
expect(err.name).toBe('BlockConflictResolvedError');
expect(err.message).toContain('block-1');
});
});

View File

@@ -1,50 +0,0 @@
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
/**
* Sentinel thrown by saveBlockWithConflictRetry after a 409 rename-mid-edit
* has been merged into local state. Surfaces to the autosave hook as an
* error (so the UI shows the retry indicator), but distinguishable from a
* genuine network failure via the code. Carries the merged block snapshot
* on its `merged` property so the caller can update local state without
* a second roundtrip.
*/
export class BlockConflictResolvedError extends Error {
readonly code = 'CONFLICT_RESOLVED' as const;
merged?: TranscriptionBlockData;
constructor(blockId: string) {
super(
`Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result`
);
this.name = 'BlockConflictResolvedError';
}
}
type MergeArgs = {
serverBlock: TranscriptionBlockData;
localText: string;
localMentions: PersonMention[];
};
/**
* Resolves a 409-Conflict from the server by combining the latest server
* snapshot with the transcriber's unsaved local edits (B12b).
*
* Rules:
* - The transcriber's typed text always wins — never overwrite their input.
* - Server is the source of truth for the displayName of any person it
* knows about; renames that just landed on the server replace stale local
* names by personId.
* - Local-only mentions added since the last save are preserved.
* - All non-mention fields (version, sortOrder, reviewed, updatedAt, ...)
* come from the server snapshot so the next save sends the current
* revision and matches the latest persisted state.
*/
export function mergeBlockOnConflict(args: MergeArgs): TranscriptionBlockData {
const serverIds = new Set(args.serverBlock.mentionedPersons.map((m) => m.personId));
const localOnly = args.localMentions.filter((m) => !serverIds.has(m.personId));
return {
...args.serverBlock,
text: args.localText,
mentionedPersons: [...args.serverBlock.mentionedPersons, ...localOnly]
};
}

View File

@@ -1,47 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
detectMention,
escapeHtml,
extractContent,
renderBody,
renderTranscriptionBody
} from './mention';
import type { MentionDTO, PersonMention } from '$lib/types';
// ─── escapeHtml ───────────────────────────────────────────────────────────────
describe('escapeHtml', () => {
it('escapes ampersand', () => {
expect(escapeHtml('AT&T')).toBe('AT&amp;T');
});
it('escapes less-than and greater-than', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
});
it('escapes double quote', () => {
expect(escapeHtml('say "hi"')).toBe('say &quot;hi&quot;');
});
it('returns empty string unchanged', () => {
expect(escapeHtml('')).toBe('');
});
it('escapes ampersand before other entities to avoid double-encoding', () => {
expect(escapeHtml('a&<b')).toBe('a&amp;&lt;b');
});
it('escapes apostrophe to &#39;', () => {
expect(escapeHtml("d'Artagnan")).toBe('d&#39;Artagnan');
});
it('does not collapse already-encoded entities (re-escapes the &)', () => {
// escapeHtml is idempotent by composition: the second pass re-escapes
// the & that was added by the first. Pin the property so the helper
// can't be "cleverly" optimised to skip it.
expect(escapeHtml('&amp;')).toBe('&amp;amp;');
});
});
import { detectMention, extractContent, renderBody } from './mention';
import type { MentionDTO } from '$lib/types';
// ─── detectMention ────────────────────────────────────────────────────────────
@@ -167,144 +126,3 @@ describe('renderBody', () => {
expect(result).not.toContain('\n');
});
});
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
describe('renderTranscriptionBody', () => {
const auguste: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440000',
displayName: 'Auguste Raddatz'
};
const hans: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440001',
displayName: 'Hans'
};
it('returns empty string for empty input', () => {
expect(renderTranscriptionBody('', [])).toBe('');
});
it('returns escaped plain text when no mentions', () => {
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
});
it('escapes < and > in plain block text', () => {
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
expect(result).toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in plain block text', () => {
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&amp;T');
});
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
expect(result).toContain('class="person-mention"');
expect(result).toContain(`data-person-id="${auguste.personId}"`);
expect(result).toContain('>Auguste Raddatz</a>');
});
it('strips the @ prefix from rendered link text (read mode)', () => {
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
// The anchor body is the bare display name — no leading @
expect(result).not.toMatch(/>@Auguste Raddatz</);
expect(result).toMatch(/>Auguste Raddatz</);
});
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
// No bare @ remains where the mention was
expect(result).not.toMatch(/@<a/);
});
it('replaces all occurrences of the same mention', () => {
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2);
});
it('does not replace plain-text occurrences without the @ trigger', () => {
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
expect(result).not.toContain('<a ');
expect(result).toBe('Auguste Raddatz war hier');
});
it('processes longer displayNames first to avoid prefix shadowing', () => {
const augusteShort: PersonMention = { personId: 'p-short', displayName: 'Auguste' };
const augusteLong: PersonMention = {
personId: 'p-long',
displayName: 'Auguste Raddatz'
};
// Sidecar order is short-first; longer match must still win for the long text
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
augusteShort,
augusteLong
]);
expect(result).toContain('href="/persons/p-long"');
expect(result).toContain('href="/persons/p-short"');
// The "Raddatz" suffix must not leak inside the short-name anchor
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
});
it('does not match @ followed by extra word characters (word boundary)', () => {
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
expect(result).not.toContain('<a ');
expect(result).toContain('@HansM');
});
it('first-sidecar-wins when two entries share the same displayName', () => {
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
const hansFirst: PersonMention = { personId: 'p-first', displayName: 'Hans' };
const hansSecond: PersonMention = { personId: 'p-second', displayName: 'Hans' };
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
expect(result).toContain('href="/persons/p-first"');
expect(result).not.toContain('href="/persons/p-second"');
const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2);
});
it('escapes HTML in displayName to prevent stored XSS', () => {
const xss: PersonMention = {
personId: 'p-xss',
displayName: '<script>alert(1)</script>'
};
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('escapes <img onerror=...> payloads in surrounding block text', () => {
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('does not double-encode HTML-entity-already-encoded payloads', () => {
// `&amp;lt;script&amp;gt;` is already-escaped HTML in the source text.
// renderTranscriptionBody must escape the literal & once → `&amp;amp;lt;...`
// — never silently decode pre-escaped entities.
const result = renderTranscriptionBody('text &amp;lt;script&amp;gt;', []);
expect(result).toBe('text &amp;amp;lt;script&amp;amp;gt;');
});
it('escapes quotes in displayName so they cannot break the href attribute', () => {
const tricky: PersonMention = {
personId: 'p-quote',
displayName: 'O"Brien'
};
const result = renderTranscriptionBody('@O"Brien', [tricky]);
// The raw `"` from the displayName must never appear inside the rendered link
// — it would terminate the attribute value early and let an attacker craft
// arbitrary attributes on the anchor. It must arrive at the browser as &quot;.
expect(result).toMatch(/>O&quot;Brien<\/a>/);
expect(result).not.toMatch(/>O"Brien<\/a>/);
});
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
const result = renderTranscriptionBody('Plain old transcription text.', []);
expect(result).toBe('Plain old transcription text.');
});
});

View File

@@ -1,4 +1,4 @@
import type { MentionDTO, PersonMention } from '$lib/types';
import type { MentionDTO } from '$lib/types';
/**
* Given the current textarea value and cursor position, returns the
@@ -44,68 +44,6 @@ export function extractContent(
return { content: text, mentionedUserIds: [...seen] };
}
/**
* Escapes the five HTML-special characters that can break out of text content
* or attribute values. & must be escaped first to avoid double-encoding.
*
* Includes the apostrophe so the helper is safe in single-quoted attribute
* values too — the renderTranscriptionBody anchor template in PR-B2 uses
* double quotes today, but a future template change shouldn't open a
* stored-XSS hole (Sina #5505 action item).
*/
export function escapeHtml(str: string): string {
return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Renders a transcription block's text segment as safe HTML for read mode.
*
* Rules:
* 1. The full text is HTML-escaped first (defense against stored XSS).
* 2. For each entry in `mentionedPersons`, every `@DisplayName` occurrence is
* replaced with `<a href="/persons/{personId}" class="person-mention" …>DisplayName</a>`.
* The `@` prefix is stripped from the rendered link text — it is an editor
* affordance, not part of the historical text (issue #362).
* 3. Longest displayNames are processed first so a short prefix in the sidecar
* cannot shadow a longer match in the text (e.g. `@Auguste` vs `@Auguste Raddatz`).
* 4. Word-boundary lookahead prevents `@Hans` from matching `@HansMüller`.
* 5. First-sidecar-wins for entries that share a displayName (deterministic
* rule per Felix decision OQ-1, comment #5339).
*/
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): string {
if (!text) return '';
let escaped = escapeHtml(text);
const seen = new Set<string>();
const unique: PersonMention[] = [];
for (const mention of mentionedPersons) {
if (seen.has(mention.displayName)) continue;
seen.add(mention.displayName);
unique.push(mention);
}
const sorted = [...unique].sort((a, b) => b.displayName.length - a.displayName.length);
for (const mention of sorted) {
const escapedDisplayName = escapeHtml(mention.displayName);
const escapedPersonId = escapeHtml(mention.personId);
const pattern = new RegExp(`@${escapeRegExp(escapedDisplayName)}(?![\\p{L}\\p{N}])`, 'gu');
const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;
escaped = escaped.replace(pattern, link);
}
return escaped;
}
/**
* Renders a comment body as safe HTML:
* 1. Escapes all HTML-special characters in the raw content
@@ -113,11 +51,19 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe
* 3. Converts newlines to <br>
*/
export function renderBody(content: string, mentions: MentionDTO[]): string {
let escaped = escapeHtml(content);
let escaped = content
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
for (const mention of mentions) {
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const escapedDisplayName = escapeHtml(displayName);
const escapedDisplayName = displayName
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
}

View File

@@ -1,65 +0,0 @@
import { describe, it, expect } from 'vitest';
import { detectPersonMention } from './personMention';
describe('detectPersonMention', () => {
it('returns null when text has no @', () => {
expect(detectPersonMention('hello world', 11)).toBeNull();
});
it('returns null when @ is preceded by a non-whitespace character (email pattern)', () => {
expect(detectPersonMention('user@example', 12)).toBeNull();
});
it('returns query for @ at the very start of string', () => {
expect(detectPersonMention('@Aug', 4)).toBe('Aug');
});
it('returns empty string immediately after @', () => {
expect(detectPersonMention('@', 1)).toBe('');
});
it('returns single-word query', () => {
expect(detectPersonMention('hi @Auguste', 11)).toBe('Auguste');
});
it('keeps the trigger active when the query has a trailing space', () => {
expect(detectPersonMention('hi @Auguste ', 12)).toBe('Auguste ');
});
it('returns multi-word query (spaces allowed)', () => {
expect(detectPersonMention('hi @Auguste Raddatz', 19)).toBe('Auguste Raddatz');
});
it('returns single-character query', () => {
expect(detectPersonMention('@M', 2)).toBe('M');
});
it('returns null when the query crosses a newline', () => {
expect(detectPersonMention('@Aug\nfoo', 8)).toBeNull();
});
it('returns null when a second @ appears in the query (next mention starts)', () => {
expect(detectPersonMention('@Aug@bar', 8)).toBeNull();
});
it('uses the most recent @ when separated by whitespace', () => {
// '@Aug @Bert' with cursor at end — the second @ is the trigger.
expect(detectPersonMention('@Aug @Bert', 10)).toBe('Bert');
});
it('returns the query when the cursor sits exactly at a newline boundary', () => {
// '@Aug\nfoo' with cursor at index 4 — right at the newline before it
// is consumed. The query is still 'Aug' because nothing past the cursor
// counts.
expect(detectPersonMention('@Aug\nfoo', 4)).toBe('Aug');
});
it('returns null when cursor is before the @', () => {
expect(detectPersonMention('@Hans', 0)).toBeNull();
});
it('uses the most recent @ in the text', () => {
// cursor is just after the second @ + a few chars
expect(detectPersonMention('hi @Anna and @Bert', 18)).toBe('Bert');
});
});

View File

@@ -1,23 +0,0 @@
/**
* Given the current textarea value and cursor position, returns the
* @-person-mention query being typed (the text after the last triggering @),
* or null if no person-mention is active.
*
* Rules — distinct from comment-mentions in `mention.ts`:
* - @ must be at the start of the string or preceded by whitespace
* - The query may contain spaces (historical persons commonly have multi-word
* display names — "Auguste Raddatz", "Maria von Müller-Schultz")
* - The query stops at a newline or at a second @ (the next mention starts)
*/
export function detectPersonMention(text: string, cursorPos: number): string | null {
const before = text.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
if (atIndex === -1) return null;
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
const query = before.slice(atIndex + 1);
if (query.includes('\n') || query.includes('@')) return null;
return query;
}

View File

@@ -1,152 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
import { BlockConflictResolvedError } from './blockConflictMerge';
import type { PersonMention } from '$lib/types';
const DOC = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const BLK = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const SERVER_BLOCK_AFTER_RENAME = {
id: BLK,
annotationId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
documentId: DOC,
text: 'old text from server',
label: null,
sortOrder: 0,
version: 7,
source: 'MANUAL' as const,
reviewed: false,
mentionedPersons: [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }]
};
function mkResponse(status: number, body?: unknown): Response {
return new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
describe('saveBlockWithConflictRetry', () => {
it('returns the server-saved block on a successful PUT', async () => {
const updated = { ...SERVER_BLOCK_AFTER_RENAME, text: 'persisted text' };
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(200, updated));
const result = await saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'persisted text',
mentionedPersons: []
});
expect(result).toEqual(updated);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl).toHaveBeenCalledWith(
`/api/documents/${DOC}/transcription-blocks/${BLK}`,
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ text: 'persisted text', mentionedPersons: [] })
})
);
});
it('throws BlockConflictResolvedError carrying the merged block on 409', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
const localMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'transcriber unsaved input',
mentionedPersons: localMentions
})
).rejects.toThrow(BlockConflictResolvedError);
expect(fetchImpl).toHaveBeenCalledTimes(2);
// First call PUT, second is the GET refetch.
expect(fetchImpl.mock.calls[0]?.[1]?.method).toBe('PUT');
expect(fetchImpl.mock.calls[1]?.[1]).toBeUndefined();
});
it('attaches the merged block to err.merged so callers can update local state', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
const localMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale displayName
];
try {
await saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'transcriber unsaved input',
mentionedPersons: localMentions
});
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(BlockConflictResolvedError);
const merged = (err as BlockConflictResolvedError).merged!;
// Local text wins.
expect(merged.text).toBe('transcriber unsaved input');
// Server displayName wins for shared personId.
expect(merged.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
]);
// Server version carried forward.
expect(merged.version).toBe(7);
}
});
it('throws BlockConflictResolvedError without merged when refetch fails', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(500));
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'x',
mentionedPersons: []
})
).rejects.toMatchObject({ code: 'CONFLICT_RESOLVED', merged: undefined });
});
it('throws Save failed for any other non-OK response', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(500));
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'x',
mentionedPersons: []
})
).rejects.toThrow('Save failed');
});
it('rejects ids that are not UUIDs (path-injection guard)', async () => {
const fetchImpl = vi.fn();
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: '../../etc/passwd',
text: 'x',
mentionedPersons: []
})
).rejects.toThrow(/Invalid id/);
expect(fetchImpl).not.toHaveBeenCalled();
});
});

View File

@@ -1,60 +0,0 @@
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
import { BlockConflictResolvedError, mergeBlockOnConflict } from '$lib/utils/blockConflictMerge';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type Args = {
fetchImpl: typeof fetch;
documentId: string;
blockId: string;
text: string;
mentionedPersons: PersonMention[];
};
/**
* Persists a transcription block edit, with built-in handling for the
* rename-mid-edit conflict (B12b).
*
* - 200/204 → resolves with the server's updated block.
* - 409 → refetches the latest server block, merges it with the
* transcriber's unsaved input via mergeBlockOnConflict, and
* throws BlockConflictResolvedError carrying the merged
* snapshot. The caller is responsible for updating local
* state with `err.merged` before surfacing the error.
* - other → throws Error('Save failed').
*
* Validates both ids against the UUID pattern before any fetch fires
* (Sina #5505 — defence-in-depth path-injection guard).
*/
export async function saveBlockWithConflictRetry(args: Args): Promise<TranscriptionBlockData> {
const { fetchImpl, documentId, blockId, text, mentionedPersons } = args;
if (!UUID_RE.test(documentId) || !UUID_RE.test(blockId)) {
throw new Error(`Invalid id for save: doc=${documentId} block=${blockId}`);
}
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const res = await fetchImpl(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons })
});
if (res.status === 409) {
const fresh = await fetchImpl(url);
if (!fresh.ok) {
throw new BlockConflictResolvedError(blockId);
}
const serverBlock = (await fresh.json()) as TranscriptionBlockData;
const merged = mergeBlockOnConflict({
serverBlock,
localText: text,
localMentions: mentionedPersons
});
const err = new BlockConflictResolvedError(blockId);
(err as BlockConflictResolvedError & { merged: TranscriptionBlockData }).merged = merged;
throw err;
}
if (!res.ok) throw new Error('Save failed');
return (await res.json()) as TranscriptionBlockData;
}

View File

@@ -60,13 +60,13 @@ function handleOverlayKeydown(event: KeyboardEvent) {
</a>
<a
href="/stammbaum"
href="/briefwechsel"
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/stammbaum')
{page.url.pathname.startsWith('/briefwechsel')
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_stammbaum()}
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
@@ -161,13 +161,13 @@ function handleOverlayKeydown(event: KeyboardEvent) {
</a>
<a
href="/stammbaum"
href="/briefwechsel"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/stammbaum')
{page.url.pathname.startsWith('/briefwechsel')
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_stammbaum()}
{m.nav_conversations()}
</a>
{#if isAdmin}

Some files were not shown because too many files have changed in this diff Show More