From 1e1921e0fa4acf3652defce0d546bf1a2da2e2a2 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 11:46:09 +0200
Subject: [PATCH 01/25] refactor(parser): expand SplitName record to 5 fields
Add title, maidenName, and annotation fields (all nullable) to
SplitName. All existing call sites pass null for new fields. Test
assertions updated to document the null-by-default contract.
Co-Authored-By: Claude Sonnet 4.6
---
.../familienarchiv/service/PersonNameParser.java | 16 +++++++++++-----
.../service/PersonNameParserTest.java | 12 ++++++++++++
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
index 2706d630..bb07e568 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
@@ -23,7 +23,13 @@ public class PersonNameParser {
private static final Pattern MULTI_SEPARATOR = Pattern.compile("\\s+(?:und|u)\\s+");
private static final Pattern SLASH_SEPARATOR = Pattern.compile("//");
- public record SplitName(String firstName, String lastName) {}
+ public record SplitName(
+ String title,
+ String firstName,
+ String lastName,
+ String maidenName,
+ String annotation
+ ) {}
/**
* Parses the "An" field from the ODS into individual normalised name strings.
@@ -118,7 +124,7 @@ public class PersonNameParser {
*/
public static SplitName split(String rawName) {
if (rawName == null || rawName.isBlank()) {
- return new SplitName("?", "?");
+ return new SplitName(null, "?", "?", null, null);
}
String cleaned = GEB_PATTERN.matcher(rawName).replaceAll("").trim();
@@ -132,15 +138,15 @@ public class PersonNameParser {
if (lastName != null) {
String firstName = cleaned.substring(0, cleaned.length() - lastName.length()).trim();
if (firstName.isBlank()) firstName = cleaned;
- return new SplitName(firstName, lastName);
+ return new SplitName(null, firstName, lastName, null, null);
}
int lastSpace = cleaned.lastIndexOf(' ');
if (lastSpace > 0) {
- return new SplitName(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim());
+ return new SplitName(null, cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim(), null, null);
}
- return new SplitName(cleaned, "?");
+ return new SplitName(null, cleaned, "?", null, null);
}
/** Returns the known last name that the given string ends with, or null. */
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index 75eab4b2..6249b8aa 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -81,6 +81,9 @@ class PersonNameParserTest {
PersonNameParser.SplitName result = PersonNameParser.split("Walter de Gruyter");
assertThat(result.firstName()).isEqualTo("Walter");
assertThat(result.lastName()).isEqualTo("de Gruyter");
+ assertThat(result.title()).isNull();
+ assertThat(result.maidenName()).isNull();
+ assertThat(result.annotation()).isNull();
}
@Test
@@ -107,22 +110,31 @@ class PersonNameParserTest {
@Test
void split_gebAnnotation_stripped() {
PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Müller");
+ assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("Eugenie");
assertThat(result.lastName()).isEqualTo("de Gruyter");
+ assertThat(result.maidenName()).isNull();
+ assertThat(result.annotation()).isNull();
}
@Test
void split_null_returnsPlaceholder() {
PersonNameParser.SplitName result = PersonNameParser.split(null);
+ assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("?");
assertThat(result.lastName()).isEqualTo("?");
+ assertThat(result.maidenName()).isNull();
+ assertThat(result.annotation()).isNull();
}
@Test
void split_blank_returnsPlaceholder() {
PersonNameParser.SplitName result = PersonNameParser.split(" ");
+ assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("?");
assertThat(result.lastName()).isEqualTo("?");
+ assertThat(result.maidenName()).isNull();
+ assertThat(result.annotation()).isNull();
}
@Test
--
2.49.1
From dea1635d759e855ae012ef33fbc032171d797801 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 11:48:08 +0200
Subject: [PATCH 02/25] refactor(parser): extract split() pipeline into named
methods
Extract stripMaidenName, normalizeDotCompressed, stripAnnotation,
stripTitle, and splitByKnownLastNameOrFallback as individually
testable pipeline steps. Each extraction method is a pass-through
until its feature issue fills in the logic.
Co-Authored-By: Claude Sonnet 4.6
---
.../service/PersonNameParser.java | 63 ++++++++++++++++---
.../service/PersonNameParserTest.java | 23 +++++++
2 files changed, 77 insertions(+), 9 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
index bb07e568..d66db4fd 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
@@ -118,35 +118,80 @@ public class PersonNameParser {
return nameParts;
}
+ // --- Pipeline result records (package-private for testing) ---
+
+ public record MaidenNameResult(String cleaned, String maidenName) {}
+ public record AnnotationResult(String cleaned, String annotation) {}
+ public record TitleResult(String cleaned, String title) {}
+ record NameParts(String firstName, String lastName) {}
+
/**
- * Splits a single full name string into firstName and lastName.
- * Uses known last names first; falls back to splitting on the last space.
+ * Splits a single full name string into a structured SplitName.
+ * Pipeline: stripMaidenName → normalizeDotCompressed → stripAnnotation → stripTitle → splitByKnownLastNameOrFallback
*/
public static SplitName split(String rawName) {
if (rawName == null || rawName.isBlank()) {
return new SplitName(null, "?", "?", null, null);
}
- String cleaned = GEB_PATTERN.matcher(rawName).replaceAll("").trim();
+ MaidenNameResult maiden = stripMaidenName(rawName);
+ String cleaned = maiden.cleaned();
- // Normalize dot-compressed names: "Dr.Fr.Zarncke" -> "Dr. Fr. Zarncke"
- if (!cleaned.contains(" ") && cleaned.contains(".")) {
- cleaned = cleaned.replace(".", ". ").trim();
+ cleaned = normalizeDotCompressed(cleaned);
+
+ AnnotationResult paren = stripAnnotation(cleaned);
+ cleaned = paren.cleaned();
+
+ TitleResult title = stripTitle(cleaned);
+ cleaned = title.cleaned();
+
+ NameParts parts = splitByKnownLastNameOrFallback(cleaned);
+
+ return new SplitName(
+ title.title(), parts.firstName(), parts.lastName(),
+ maiden.maidenName(), paren.annotation()
+ );
+ }
+
+ /** Strips "geb. Xxx" maiden-name annotations. Pass-through until #209. */
+ public static MaidenNameResult stripMaidenName(String input) {
+ String cleaned = GEB_PATTERN.matcher(input).replaceAll("").trim();
+ return new MaidenNameResult(cleaned, null);
+ }
+
+ /** Normalizes dot-compressed names: "Dr.Fr.Zarncke" → "Dr. Fr. Zarncke" */
+ static String normalizeDotCompressed(String input) {
+ if (!input.contains(" ") && input.contains(".")) {
+ return input.replace(".", ". ").trim();
}
+ return input;
+ }
+ /** Strips parenthesized annotations. Pass-through until #210. */
+ public static AnnotationResult stripAnnotation(String input) {
+ return new AnnotationResult(input, null);
+ }
+
+ /** Strips title prefixes. Pass-through until #212. */
+ public static TitleResult stripTitle(String input) {
+ return new TitleResult(input, null);
+ }
+
+ /** Splits a cleaned name into firstName/lastName using known last names or last-space fallback. */
+ static NameParts splitByKnownLastNameOrFallback(String cleaned) {
String lastName = findKnownLastName(cleaned);
if (lastName != null) {
String firstName = cleaned.substring(0, cleaned.length() - lastName.length()).trim();
if (firstName.isBlank()) firstName = cleaned;
- return new SplitName(null, firstName, lastName, null, null);
+ return new NameParts(firstName, lastName);
}
int lastSpace = cleaned.lastIndexOf(' ');
if (lastSpace > 0) {
- return new SplitName(null, cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim(), null, null);
+ return new NameParts(cleaned.substring(0, lastSpace).trim(), cleaned.substring(lastSpace + 1).trim());
}
- return new SplitName(null, cleaned, "?", null, null);
+ return new NameParts(cleaned, "?");
}
/** Returns the known last name that the given string ends with, or null. */
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index 6249b8aa..deecacd3 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -256,4 +256,27 @@ class PersonNameParserTest {
List result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter");
}
+
+ // --- pipeline pass-through methods ---
+
+ @Test
+ void stripMaidenName_isPassthrough() {
+ PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter");
+ assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
+ assertThat(result.maidenName()).isNull();
+ }
+
+ @Test
+ void stripAnnotation_isPassthrough() {
+ PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Walter de Gruyter");
+ assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
+ assertThat(result.annotation()).isNull();
+ }
+
+ @Test
+ void stripTitle_isPassthrough() {
+ PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Walter de Gruyter");
+ assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
+ assertThat(result.title()).isNull();
+ }
}
--
2.49.1
From 8101ddb697d42acc1e45c6a0ccf07ab67cf32b14 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 11:50:19 +0200
Subject: [PATCH 03/25] feat(model): add PersonType enum and MAIDEN_NAME alias
type
PersonType has 5 values: PERSON, INSTITUTION, GROUP, UNKNOWN, SKIP.
SKIP is intentionally excluded from the DB CHECK constraint (added
in migration) as defense-in-depth. MAIDEN_NAME added to
PersonNameAliasType for #209.
Co-Authored-By: Claude Sonnet 4.6
---
.../model/PersonNameAliasType.java | 1 +
.../raddatz/familienarchiv/model/PersonType.java | 9 +++++++++
.../service/PersonNameParserTest.java | 16 ++++++++++++++++
3 files changed, 26 insertions(+)
create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java
index 38ad90cf..e4c089e6 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonNameAliasType.java
@@ -4,5 +4,6 @@ public enum PersonNameAliasType {
BIRTH,
WIDOWED,
DIVORCED,
+ MAIDEN_NAME,
OTHER
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java
new file mode 100644
index 00000000..db95b5bc
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonType.java
@@ -0,0 +1,9 @@
+package org.raddatz.familienarchiv.model;
+
+public enum PersonType {
+ PERSON,
+ INSTITUTION,
+ GROUP,
+ UNKNOWN,
+ SKIP
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index deecacd3..a1148fa7 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.model.PersonNameAliasType;
+import org.raddatz.familienarchiv.model.PersonType;
import java.util.List;
@@ -279,4 +281,18 @@ class PersonNameParserTest {
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
assertThat(result.title()).isNull();
}
+
+ // --- enum values ---
+
+ @Test
+ void personType_hasFiveValues() {
+ assertThat(PersonType.values()).containsExactly(
+ PersonType.PERSON, PersonType.INSTITUTION, PersonType.GROUP,
+ PersonType.UNKNOWN, PersonType.SKIP);
+ }
+
+ @Test
+ void personNameAliasType_includesMaidenName() {
+ assertThat(PersonNameAliasType.valueOf("MAIDEN_NAME")).isEqualTo(PersonNameAliasType.MAIDEN_NAME);
+ }
}
--
2.49.1
From 9f14648dc32aeda5c1d24ef2edc319885fb5813a Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 11:53:07 +0200
Subject: [PATCH 04/25] feat(model): add title, personType, displayName to
Person entity
- Add title (nullable VARCHAR) and personType (enum, default PERSON)
- Make firstName nullable for non-person entities
- Add @Transient getDisplayName() as single source of truth for
name display, exposed via @Schema(READ_ONLY, REQUIRED)
Co-Authored-By: Claude Sonnet 4.6
---
.../raddatz/familienarchiv/model/Person.java | 22 +++++++-
.../familienarchiv/model/PersonTest.java | 53 +++++++++++++++++++
2 files changed, 73 insertions(+), 2 deletions(-)
create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java
index 3bd6f418..b359fd7b 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java
@@ -21,14 +21,22 @@ public class Person {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
- @Column(nullable = false)
- @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ @Column(name = "title")
+ private String title;
+
+ @Column(nullable = true)
private String firstName;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String lastName;
+ @Enumerated(EnumType.STRING)
+ @Column(name = "person_type", nullable = false)
+ @Builder.Default
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ private PersonType personType = PersonType.PERSON;
+
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
private String alias;
@@ -46,4 +54,14 @@ public class Person {
@JsonIgnore
@Builder.Default
private List nameAliases = new ArrayList<>();
+
+ @Transient
+ @Schema(accessMode = Schema.AccessMode.READ_ONLY, requiredMode = Schema.RequiredMode.REQUIRED)
+ public String getDisplayName() {
+ StringBuilder sb = new StringBuilder();
+ if (title != null) sb.append(title).append(" ");
+ if (firstName != null) sb.append(firstName).append(" ");
+ sb.append(lastName);
+ return sb.toString().trim();
+ }
}
\ No newline at end of file
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java
new file mode 100644
index 00000000..72674925
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/model/PersonTest.java
@@ -0,0 +1,53 @@
+package org.raddatz.familienarchiv.model;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PersonTest {
+
+ @Test
+ void getDisplayName_withAllFields() {
+ Person person = Person.builder()
+ .title("Dr.")
+ .firstName("Walter")
+ .lastName("de Gruyter")
+ .build();
+ assertThat(person.getDisplayName()).isEqualTo("Dr. Walter de Gruyter");
+ }
+
+ @Test
+ void getDisplayName_withoutTitle() {
+ Person person = Person.builder()
+ .firstName("Clara")
+ .lastName("Cram")
+ .build();
+ assertThat(person.getDisplayName()).isEqualTo("Clara Cram");
+ }
+
+ @Test
+ void getDisplayName_withNullFirstName() {
+ Person person = Person.builder()
+ .lastName("Gesellschafter des Verlages")
+ .build();
+ assertThat(person.getDisplayName()).isEqualTo("Gesellschafter des Verlages");
+ }
+
+ @Test
+ void getDisplayName_withTitleAndNullFirstName() {
+ Person person = Person.builder()
+ .title("Tante")
+ .lastName("Molly")
+ .build();
+ assertThat(person.getDisplayName()).isEqualTo("Tante Molly");
+ }
+
+ @Test
+ void personType_defaultsToPerson() {
+ Person person = Person.builder()
+ .firstName("Clara")
+ .lastName("Cram")
+ .build();
+ assertThat(person.getPersonType()).isEqualTo(PersonType.PERSON);
+ }
+}
--
2.49.1
From 92f1a112f5bbf8c21e3c63d81da296b65207faca Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 11:55:04 +0200
Subject: [PATCH 05/25] feat(migration): V22 add title, person_type, nullable
first_name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add title VARCHAR(50) column
- Add person_type VARCHAR(20) NOT NULL DEFAULT 'PERSON' with CHECK
constraint (PERSON, INSTITUTION, GROUP, UNKNOWN — SKIP excluded)
- Drop NOT NULL on first_name for non-person entities
Co-Authored-By: Claude Sonnet 4.6
---
.../V22__person_type_title_nullable_firstname.sql | 12 ++++++++++++
1 file changed, 12 insertions(+)
create mode 100644 backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql
diff --git a/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql b/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql
new file mode 100644
index 00000000..a01b6373
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V22__person_type_title_nullable_firstname.sql
@@ -0,0 +1,12 @@
+-- Add title column for honorifics/salutations (Dr., Tante, Frau, etc.)
+ALTER TABLE persons ADD COLUMN title VARCHAR(50);
+
+-- Add person_type column to distinguish persons from institutions/groups.
+-- SKIP is intentionally omitted: it exists in the Java enum for parse-time
+-- filtering but must never be persisted. The CHECK constraint enforces this.
+ALTER TABLE persons ADD COLUMN person_type VARCHAR(20) NOT NULL DEFAULT 'PERSON';
+ALTER TABLE persons ADD CONSTRAINT chk_person_type
+ CHECK (person_type IN ('PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'));
+
+-- Make first_name nullable for non-person entities (institutions, groups)
+ALTER TABLE persons ALTER COLUMN first_name DROP NOT NULL;
--
2.49.1
From de2cc677a9720e7ef6952642e27383ca4b3d53f3 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 11:59:41 +0200
Subject: [PATCH 06/25] fix(search): handle null firstName in all search
queries
Use COALESCE to convert null firstName to empty string in:
- PersonRepository.searchByName (JPQL)
- PersonRepository.searchWithDocumentCount (native SQL)
- PersonRepository.findCorrespondentsWithFilter (native SQL)
- DocumentSpecifications.hasText (Criteria API, sender + receiver)
Co-Authored-By: Claude Sonnet 4.6
---
.../repository/DocumentSpecifications.java | 4 ++--
.../repository/PersonRepository.java | 12 +++++-----
.../repository/PersonRepositoryTest.java | 22 +++++++++++++++++++
3 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java
index ee9550c1..d8d572bc 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java
@@ -41,7 +41,7 @@ public class DocumentSpecifications {
cb.equal(receiverRoot.get("id"), root.get("id")),
cb.or(
cb.like(cb.lower(receiverJoin.get("lastName")), likePattern),
- cb.like(cb.lower(receiverJoin.get("firstName")), likePattern)
+ cb.like(cb.lower(cb.coalesce(receiverJoin.get("firstName"), "")), likePattern)
)
);
@@ -74,7 +74,7 @@ public class DocumentSpecifications {
cb.like(cb.lower(root.get("transcription")), likePattern),
cb.like(cb.lower(root.get("location")), likePattern),
cb.like(cb.lower(senderJoin.get("lastName")), likePattern),
- cb.like(cb.lower(senderJoin.get("firstName")), likePattern),
+ cb.like(cb.lower(cb.coalesce(senderJoin.get("firstName"), "")), likePattern),
cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern),
cb.exists(receiverSub),
cb.exists(receiverAliasSub),
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java
index abbed802..e41be561 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java
@@ -16,8 +16,8 @@ import org.springframework.stereotype.Repository;
public interface PersonRepository extends JpaRepository {
@Query("SELECT DISTINCT p FROM Person p LEFT JOIN p.nameAliases a WHERE " +
- "LOWER(CONCAT(p.firstName,' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
- "LOWER(CONCAT(p.lastName, ' ', p.firstName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
+ "LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
+ "LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"ORDER BY p.lastName ASC, p.firstName ASC")
@@ -52,8 +52,8 @@ public interface PersonRepository extends JpaRepository {
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
LEFT JOIN person_name_aliases a ON a.person_id = p.id
- WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
- OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%'))
+ WHERE LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
+ 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.first_name, p.last_name, p.alias, p.birth_year, p.death_year, p.notes
@@ -98,8 +98,8 @@ public interface PersonRepository extends JpaRepository {
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
) shared ON shared.other_id = p.id
WHERE p.id != :personId
- AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
- OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%'))
+ AND (LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
+ OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:q,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%')))
GROUP BY p.id
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java
index b0873d35..26eccf6a 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java
@@ -440,4 +440,26 @@ class PersonRepositoryTest {
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
}
+
+ // ─── null firstName handling ────────────────────────────────────────────
+
+ @Test
+ void searchByName_findsPersonWithNullFirstName() {
+ personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());
+
+ List result = personRepository.searchByName("Gesellschafter");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
+ }
+
+ @Test
+ void searchWithDocumentCount_findsPersonWithNullFirstName() {
+ personRepository.save(Person.builder().lastName("Gesellschafter des Verlages").build());
+
+ List result = personRepository.searchWithDocumentCount("Gesellschafter");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
+ }
}
--
2.49.1
From 0ce803c7f194b60e72dd585459e61cd41ab88972 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:06:38 +0200
Subject: [PATCH 07/25] build(frontend): regenerate API types for Person
changes
Person type now includes displayName (readonly, required), title,
personType (required enum), and firstName is optional.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/generated/api.ts | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts
index 534c8066..a994b73b 100644
--- a/frontend/src/lib/generated/api.ts
+++ b/frontend/src/lib/generated/api.ts
@@ -952,14 +952,18 @@ export interface components {
Person: {
/** Format: uuid */
id: string;
- firstName: string;
+ title?: string;
+ firstName?: string;
lastName: string;
+ /** @enum {string} */
+ personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
+ readonly displayName: string;
};
DocumentUpdateDTO: {
title?: string;
@@ -1047,19 +1051,18 @@ export interface components {
newPassword?: string;
};
PersonNameAliasDTO: {
- lastName?: string;
+ lastName: string;
firstName?: string;
/** @enum {string} */
- type?: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
+ type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
};
PersonNameAlias: {
/** Format: uuid */
id: string;
- person?: components["schemas"]["Person"];
lastName: string;
firstName?: string;
/** @enum {string} */
- type: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
+ type: "BIRTH" | "WIDOWED" | "DIVORCED" | "MAIDEN_NAME" | "OTHER";
/** Format: int32 */
sortOrder: number;
/** Format: date-time */
@@ -1228,10 +1231,10 @@ export interface components {
/** Format: int32 */
number?: number;
sort?: components["schemas"]["SortObject"];
- /** Format: int32 */
- numberOfElements?: number;
first?: boolean;
last?: boolean;
+ /** Format: int32 */
+ numberOfElements?: number;
empty?: boolean;
};
PageableObject: {
--
2.49.1
From f11d8a38edf9424df28d87d170ae3ff282cd7a9b Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:22:30 +0200
Subject: [PATCH 08/25] feat(frontend): replace all name concatenation with
displayName
- Add displayName default method to PersonSummaryDTO
- Update native SQL queries to include title, person_type columns
- Add getInitials() utility to personFormat.ts
- Update abbreviateName/abbreviateCompact for nullable firstName
- Replace firstName+lastName concatenation with displayName in all
person-displaying components and server load files
- Regenerate API types with displayName on Person and PersonSummaryDTO
Co-Authored-By: Claude Sonnet 4.6
---
.../familienarchiv/dto/PersonSummaryDTO.java | 10 ++++++++++
.../repository/PersonRepository.java | 8 +++++---
.../controller/PersonControllerTest.java | 2 ++
.../components/DashboardRecentDocuments.svelte | 2 +-
.../components/DocumentMetadataDrawer.svelte | 8 ++++----
.../src/lib/components/DocumentTopBar.svelte | 2 +-
.../lib/components/OverflowPillButton.svelte | 5 ++---
frontend/src/lib/components/PersonChip.svelte | 14 +++++---------
.../src/lib/components/PersonChipRow.svelte | 2 +-
.../lib/components/PersonMultiSelect.svelte | 5 ++---
.../src/lib/components/PersonTypeahead.svelte | 4 ++--
frontend/src/lib/generated/api.ts | 7 +++++--
frontend/src/lib/utils/personFormat.ts | 9 ++++++++-
frontend/src/routes/+page.server.ts | 4 ++--
frontend/src/routes/DocumentList.svelte | 8 ++++----
.../src/routes/briefwechsel/+page.server.ts | 8 ++++----
.../briefwechsel/ConversationTimeline.svelte | 13 +++++++++----
.../CorrespondentSuggestionsDropdown.svelte | 9 ++++++---
.../src/routes/conversations/+page.server.ts | 8 ++++----
.../conversations/ConversationTimeline.svelte | 9 +++++++--
.../routes/documents/[id]/edit/+page.svelte | 2 +-
.../src/routes/documents/new/+page.server.ts | 18 +++++++++++++++---
frontend/src/routes/documents/new/+page.svelte | 5 ++---
frontend/src/routes/enrich/[id]/+page.svelte | 2 +-
frontend/src/routes/persons/+page.svelte | 5 ++---
frontend/src/routes/persons/[id]/+page.svelte | 4 ++--
.../routes/persons/[id]/NameHistoryCard.svelte | 2 +-
.../src/routes/persons/[id]/PersonCard.svelte | 8 ++++----
.../persons/[id]/PersonMergePanel.svelte | 4 ++--
.../src/routes/persons/[id]/edit/+page.svelte | 3 +--
.../persons/[id]/edit/PersonDangerZone.svelte | 2 +-
.../persons/[id]/edit/PersonEditForm.svelte | 2 +-
32 files changed, 117 insertions(+), 77 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java
index d31539ac..edb448f8 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java
@@ -9,11 +9,21 @@ import java.util.UUID;
*/
public interface PersonSummaryDTO {
UUID getId();
+ String getTitle();
String getFirstName();
String getLastName();
+ String getPersonType();
String getAlias();
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
long getDocumentCount();
+
+ default String getDisplayName() {
+ StringBuilder sb = new StringBuilder();
+ if (getTitle() != null) sb.append(getTitle()).append(" ");
+ if (getFirstName() != null) sb.append(getFirstName()).append(" ");
+ sb.append(getLastName());
+ return sb.toString().trim();
+ }
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java
index e41be561..782dde24 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java
@@ -35,7 +35,8 @@ public interface PersonRepository extends JpaRepository {
// --- PersonSummaryDTO with document count ---
@Query(value = """
- SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
+ 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,
(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
@@ -46,7 +47,8 @@ public interface PersonRepository extends JpaRepository {
List findAllWithDocumentCount();
@Query(value = """
- SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
+ 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,
(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
@@ -56,7 +58,7 @@ public interface PersonRepository extends JpaRepository {
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.first_name, p.last_name, p.alias, p.birth_year, p.death_year, p.notes
+ 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)
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
index bd41be36..02973927 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
@@ -73,8 +73,10 @@ class PersonControllerTest {
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
return new PersonSummaryDTO() {
public java.util.UUID getId() { return UUID.randomUUID(); }
+ public String getTitle() { return null; }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
+ public String getPersonType() { return "PERSON"; }
public String getAlias() { return null; }
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
diff --git a/frontend/src/lib/components/DashboardRecentDocuments.svelte b/frontend/src/lib/components/DashboardRecentDocuments.svelte
index bfe10e0f..25b93c8b 100644
--- a/frontend/src/lib/components/DashboardRecentDocuments.svelte
+++ b/frontend/src/lib/components/DashboardRecentDocuments.svelte
@@ -7,7 +7,7 @@ type Document = {
id: string;
title: string;
updatedAt?: string;
- sender?: { id: string; firstName: string; lastName: string };
+ sender?: { id: string; firstName?: string | null; lastName: string; displayName: string };
};
type StatsDTO = components['schemas']['StatsDTO'];
diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte b/frontend/src/lib/components/DocumentMetadataDrawer.svelte
index 22e8b13a..d057a993 100644
--- a/frontend/src/lib/components/DocumentMetadataDrawer.svelte
+++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte
@@ -2,9 +2,9 @@
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
-import { personAvatarColor } from '$lib/utils/personFormat';
+import { getInitials as calcInitials, personAvatarColor } from '$lib/utils/personFormat';
-type Person = { id: string; firstName: string; lastName: string };
+type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Props = {
@@ -33,11 +33,11 @@ let showAllReceivers = $state(false);
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
function getInitials(person: Person): string {
- return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
+ return calcInitials(person);
}
function getFullName(person: Person): string {
- return `${person.firstName} ${person.lastName}`;
+ return person.displayName;
}
diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte
index 7d610706..c7604e7c 100644
--- a/frontend/src/lib/components/DocumentTopBar.svelte
+++ b/frontend/src/lib/components/DocumentTopBar.svelte
@@ -7,7 +7,7 @@ import PersonChipRow from './PersonChipRow.svelte';
import OverflowPillButton from './OverflowPillButton.svelte';
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
-type Person = { id: string; firstName: string; lastName: string };
+type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Doc = {
diff --git a/frontend/src/lib/components/OverflowPillButton.svelte b/frontend/src/lib/components/OverflowPillButton.svelte
index 5541edc7..3661b5c4 100644
--- a/frontend/src/lib/components/OverflowPillButton.svelte
+++ b/frontend/src/lib/components/OverflowPillButton.svelte
@@ -3,7 +3,7 @@ import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
-type Person = { id: string; firstName: string; lastName: string };
+type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Props = {
extraCount: number;
@@ -67,8 +67,7 @@ function handleKeydown(e: KeyboardEvent) {
href="/persons/{person.id}"
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
>
- {person.firstName}
- {person.lastName}
+ {person.displayName}
{/each}
diff --git a/frontend/src/lib/components/PersonChip.svelte b/frontend/src/lib/components/PersonChip.svelte
index d17e7365..97796c00 100644
--- a/frontend/src/lib/components/PersonChip.svelte
+++ b/frontend/src/lib/components/PersonChip.svelte
@@ -1,7 +1,7 @@
{initials}
- {displayName}
+ {name}
diff --git a/frontend/src/lib/components/PersonChipRow.svelte b/frontend/src/lib/components/PersonChipRow.svelte
index e50ed3e3..d292e22d 100644
--- a/frontend/src/lib/components/PersonChipRow.svelte
+++ b/frontend/src/lib/components/PersonChipRow.svelte
@@ -2,7 +2,7 @@
import PersonChip from './PersonChip.svelte';
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
-type Person = { id: string; firstName: string; lastName: string };
+type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Props = {
sender: Person | null | undefined;
diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte
index 536ef97b..7847787f 100644
--- a/frontend/src/lib/components/PersonMultiSelect.svelte
+++ b/frontend/src/lib/components/PersonMultiSelect.svelte
@@ -73,8 +73,7 @@ function removePerson(id: string | undefined) {
- {person.firstName}
- {person.lastName}
+ {person.displayName}
{#if doc.sender}
- {doc.sender.firstName} {doc.sender.lastName}
+ {doc.sender.displayName}
{:else}
{m.docs_list_unknown()}
{/if}
@@ -114,7 +114,7 @@ let {
>
{#if doc.receivers && doc.receivers.length > 0}
- {doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
+ {doc.receivers.map((p) => p.displayName).join(', ')}
{:else}
{m.docs_list_unknown()}
diff --git a/frontend/src/routes/briefwechsel/+page.server.ts b/frontend/src/routes/briefwechsel/+page.server.ts
index ac9f12c4..38427f5c 100644
--- a/frontend/src/routes/briefwechsel/+page.server.ts
+++ b/frontend/src/routes/briefwechsel/+page.server.ts
@@ -52,8 +52,8 @@ export async function load({ url, fetch, locals }) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
- const p = result.data as { firstName: string; lastName: string } | undefined;
- if (p) senderName = `${p.firstName} ${p.lastName}`;
+ const p = result.data as { displayName: string } | undefined;
+ if (p) senderName = p.displayName;
})
);
}
@@ -65,8 +65,8 @@ export async function load({ url, fetch, locals }) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
- const p = result.data as { firstName: string; lastName: string } | undefined;
- if (p) receiverName = `${p.firstName} ${p.lastName}`;
+ const p = result.data as { displayName: string } | undefined;
+ if (p) receiverName = p.displayName;
})
);
}
diff --git a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte
index 4a2842e7..31b45467 100644
--- a/frontend/src/routes/briefwechsel/ConversationTimeline.svelte
+++ b/frontend/src/routes/briefwechsel/ConversationTimeline.svelte
@@ -10,8 +10,13 @@ interface Props {
documentDate?: string;
location?: string;
status: string;
- sender?: { id: string; firstName: string; lastName: string } | null;
- receivers?: { id: string; firstName: string; lastName: string }[];
+ sender?: {
+ id: string;
+ firstName?: string | null;
+ lastName: string;
+ displayName: string;
+ } | null;
+ receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
}[];
senderId: string;
receiverId?: string;
@@ -67,9 +72,9 @@ function statusDotClass(status: string): string {
function otherPartyName(doc: (typeof documents)[number]): string {
if (doc.sender?.id === senderId) {
const r = doc.receivers?.[0];
- return r ? `${r.firstName} ${r.lastName}` : m.conv_no_party();
+ return r ? r.displayName : m.conv_no_party();
}
- return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : m.conv_no_party();
+ return doc.sender ? doc.sender.displayName : m.conv_no_party();
}
const newDocUrl = $derived(
diff --git a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte
index 1cbf8a36..29df8b1f 100644
--- a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte
+++ b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte
@@ -4,8 +4,9 @@ import { clickOutside } from '$lib/actions/clickOutside';
interface Correspondent {
id: string;
- firstName: string;
+ firstName?: string | null;
lastName: string;
+ displayName: string;
}
interface Props {
@@ -41,7 +42,9 @@ function handleKeydown(event: KeyboardEvent, container: HTMLElement) {
}
function getInitials(person: Correspondent): string {
- return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
+ if (person.firstName)
+ return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
+ return person.lastName.substring(0, 2).toUpperCase();
}
@@ -78,7 +81,7 @@ function getInitials(person: Correspondent): string {
{getInitials(person)}
- {person.lastName}, {person.firstName}
+ {person.displayName}
{/each}
{/if}
diff --git a/frontend/src/routes/conversations/+page.server.ts b/frontend/src/routes/conversations/+page.server.ts
index 62786d44..d6f4cfd6 100644
--- a/frontend/src/routes/conversations/+page.server.ts
+++ b/frontend/src/routes/conversations/+page.server.ts
@@ -39,8 +39,8 @@ export async function load({ url, fetch }) {
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
- const p = data as { firstName: string; lastName: string } | undefined;
- if (p) senderName = `${p.firstName} ${p.lastName}`;
+ const p = data as { displayName: string } | undefined;
+ if (p) senderName = p.displayName;
})
);
}
@@ -48,8 +48,8 @@ export async function load({ url, fetch }) {
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
- const p = data as { firstName: string; lastName: string } | undefined;
- if (p) receiverName = `${p.firstName} ${p.lastName}`;
+ const p = data as { displayName: string } | undefined;
+ if (p) receiverName = p.displayName;
})
);
}
diff --git a/frontend/src/routes/conversations/ConversationTimeline.svelte b/frontend/src/routes/conversations/ConversationTimeline.svelte
index 6fc8bc56..71422858 100644
--- a/frontend/src/routes/conversations/ConversationTimeline.svelte
+++ b/frontend/src/routes/conversations/ConversationTimeline.svelte
@@ -15,7 +15,12 @@ let {
documentDate?: string;
location?: string;
status: string;
- sender?: { id: string; firstName: string; lastName: string } | null;
+ sender?: {
+ id: string;
+ firstName?: string | null;
+ lastName: string;
+ displayName: string;
+ } | null;
}[];
senderId: string;
receiverId: string;
@@ -106,7 +111,7 @@ const enrichedDocuments = $derived(
: 'border-line bg-surface text-ink'}"
>
{#if doc.sender}
- {doc.sender.firstName[0]}{doc.sender.lastName[0]}
+ {doc.sender.firstName ? doc.sender.firstName[0] : doc.sender.lastName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}
diff --git a/frontend/src/routes/documents/[id]/edit/+page.svelte b/frontend/src/routes/documents/[id]/edit/+page.svelte
index a8df93d8..c0235f0e 100644
--- a/frontend/src/routes/documents/[id]/edit/+page.svelte
+++ b/frontend/src/routes/documents/[id]/edit/+page.svelte
@@ -54,7 +54,7 @@ let selectedReceivers = $state(doc.receivers ?? []);
bind:selectedReceivers={selectedReceivers}
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
- initialSenderName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
+ initialSenderName={doc.sender ? doc.sender.displayName : ''}
/>
[] = [];
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
- if (data) initialSenderName = `${data.firstName} ${data.lastName}`;
+ if (data) initialSenderName = data.displayName;
})
);
}
@@ -40,7 +45,14 @@ export async function load({
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
if (data)
- initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }];
+ initialReceivers = [
+ {
+ id: data.id!,
+ firstName: data.firstName,
+ lastName: data.lastName,
+ displayName: data.displayName
+ }
+ ];
})
);
}
diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte
index 42c8ab3b..389151c4 100644
--- a/frontend/src/routes/documents/new/+page.svelte
+++ b/frontend/src/routes/documents/new/+page.svelte
@@ -12,9 +12,8 @@ let { data, form } = $props();
let tags: string[] = $state([]);
let senderId = $state(untrack(() => data.initialSenderId));
-let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
- untrack(() => data.initialReceivers)
-);
+let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
+ $state(untrack(() => data.initialReceivers));
let parsedSuggestion = $state({});
diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte
index 21ceb7eb..02a147c3 100644
--- a/frontend/src/routes/enrich/[id]/+page.svelte
+++ b/frontend/src/routes/enrich/[id]/+page.svelte
@@ -120,7 +120,7 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender
- ? `${doc.sender.firstName} ${doc.sender.lastName}`
+ ? doc.sender.displayName
: ''}
/>
diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte
index 7da342c9..9487c82f 100644
--- a/frontend/src/routes/persons/+page.svelte
+++ b/frontend/src/routes/persons/+page.svelte
@@ -99,13 +99,12 @@ function handleSearch() {
- {person.firstName?.[0]}{person.lastName?.[0]}
+ {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
- {person.firstName}
- {person.lastName}
+ {person.displayName}
diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte
index b847f1fc..9ca2f179 100644
--- a/frontend/src/routes/persons/[id]/+page.svelte
+++ b/frontend/src/routes/persons/[id]/+page.svelte
@@ -23,7 +23,7 @@ const coCorrespondents = $derived.by(() => {
else
freq.set(key, {
id: receiver.id,
- name: `${receiver.firstName} ${receiver.lastName}`,
+ name: receiver.displayName,
count: 1
});
}
@@ -37,7 +37,7 @@ const coCorrespondents = $derived.by(() => {
else
freq.set(key, {
id: doc.sender.id,
- name: `${doc.sender.firstName} ${doc.sender.lastName}`,
+ name: doc.sender.displayName,
count: 1
});
}
diff --git a/frontend/src/routes/persons/[id]/NameHistoryCard.svelte b/frontend/src/routes/persons/[id]/NameHistoryCard.svelte
index 84e34852..065de0e7 100644
--- a/frontend/src/routes/persons/[id]/NameHistoryCard.svelte
+++ b/frontend/src/routes/persons/[id]/NameHistoryCard.svelte
@@ -9,7 +9,7 @@ interface Props {
type: string;
sortOrder: number;
}>;
- personFirstName: string;
+ personFirstName?: string | null;
}
let { aliases, personFirstName }: Props = $props();
diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte b/frontend/src/routes/persons/[id]/PersonCard.svelte
index a14217bc..639d58ad 100644
--- a/frontend/src/routes/persons/[id]/PersonCard.svelte
+++ b/frontend/src/routes/persons/[id]/PersonCard.svelte
@@ -8,8 +8,9 @@ let {
}: {
person: {
id: string;
- firstName: string;
+ firstName?: string | null;
lastName: string;
+ displayName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
@@ -29,14 +30,13 @@ let {
- {person.firstName[0]}{person.lastName[0]}
+ {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
- {person.firstName}
- {person.lastName}
+ {person.displayName}
diff --git a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte
index 0a771b0e..92af8bb9 100644
--- a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte
+++ b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte
@@ -7,7 +7,7 @@ let {
person,
form
}: {
- person: { firstName: string; lastName: string };
+ person: { displayName: string };
form?: { mergeError?: string } | null;
} = $props();
@@ -74,7 +74,7 @@ let showMergeConfirm = $state(false);
{#if showMergeConfirm}
- {m.person_merge_warning()} {person.firstName} {person.lastName}
+ {m.person_merge_warning()} {person.displayName}
{m.person_merge_will_be_deleted()}
{/if}
diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte
index dd0f539e..1f38737e 100644
--- a/frontend/src/routes/persons/[id]/edit/+page.svelte
+++ b/frontend/src/routes/persons/[id]/edit/+page.svelte
@@ -30,8 +30,7 @@ const person = $derived(data.person);
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
- {person.firstName}
- {person.lastName}
+ {person.displayName}
{m.person_edit_heading()}
diff --git a/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte b/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte
index b5fe3025..4161e1fc 100644
--- a/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte
+++ b/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte
@@ -6,7 +6,7 @@ let {
person,
form
}: {
- person: { id: string; firstName: string; lastName: string };
+ person: { id: string; firstName?: string | null; lastName: string; displayName: string };
form?: { mergeError?: string } | null;
} = $props();
diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte
index 380f343f..9d88464d 100644
--- a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte
+++ b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte
@@ -5,7 +5,7 @@ let {
person
}: {
person: {
- firstName: string;
+ firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
--
2.49.1
From 9caef1e79e3f71d5b0cfce722cba4130ed2ba9f6 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:46:36 +0200
Subject: [PATCH 09/25] feat(i18n): add PersonType and MAIDEN_NAME translation
keys
Add translations for PersonType values (PERSON, INSTITUTION, GROUP,
UNKNOWN) and PersonNameAliasType.MAIDEN_NAME in de/en/es.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/messages/de.json | 5 +++++
frontend/messages/en.json | 5 +++++
frontend/messages/es.json | 5 +++++
3 files changed, 15 insertions(+)
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 58a677b2..1bd5f8a2 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -485,7 +485,12 @@
"person_alias_type_BIRTH": "geborene/r",
"person_alias_type_WIDOWED": "verwitwete/r",
"person_alias_type_DIVORCED": "geschiedene/r",
+ "person_alias_type_MAIDEN_NAME": "Geburtsname",
"person_alias_type_OTHER": "Sonstiger Name",
+ "person_type_PERSON": "Person",
+ "person_type_INSTITUTION": "Institution",
+ "person_type_GROUP": "Gruppe",
+ "person_type_UNKNOWN": "Unbekannt",
"person_alias_add_heading": "Name hinzufuegen",
"person_alias_label_type": "Art",
"person_alias_label_last_name": "Nachname",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 31a7d6a6..4c247132 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -485,7 +485,12 @@
"person_alias_type_BIRTH": "Birth name",
"person_alias_type_WIDOWED": "Name as widow/widower",
"person_alias_type_DIVORCED": "Name after divorce",
+ "person_alias_type_MAIDEN_NAME": "Maiden name",
"person_alias_type_OTHER": "Other name",
+ "person_type_PERSON": "Person",
+ "person_type_INSTITUTION": "Institution",
+ "person_type_GROUP": "Group",
+ "person_type_UNKNOWN": "Unknown",
"person_alias_add_heading": "Add name",
"person_alias_label_type": "Type",
"person_alias_label_last_name": "Last name",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 5beeb2f2..89da4185 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -485,7 +485,12 @@
"person_alias_type_BIRTH": "Nombre de nacimiento",
"person_alias_type_WIDOWED": "Nombre como viuda/viudo",
"person_alias_type_DIVORCED": "Nombre tras el divorcio",
+ "person_alias_type_MAIDEN_NAME": "Apellido de soltera",
"person_alias_type_OTHER": "Otro nombre",
+ "person_type_PERSON": "Persona",
+ "person_type_INSTITUTION": "Institución",
+ "person_type_GROUP": "Grupo",
+ "person_type_UNKNOWN": "Desconocido",
"person_alias_add_heading": "Agregar nombre",
"person_alias_label_type": "Tipo",
"person_alias_label_last_name": "Apellido",
--
2.49.1
From 1aabd9826cf958b89c4a4795461457daa76684ad Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:47:15 +0200
Subject: [PATCH 10/25] test(frontend): update mock data for displayName and
nullable firstName
Add displayName and personType to all Person mock objects in
component and page tests. Update assertions from reversed
"lastName, firstName" format to forward-order displayName.
Co-Authored-By: Claude Sonnet 4.6
---
.../DocumentMetadataDrawer.svelte.spec.ts | 6 +-
.../PersonMultiSelect.svelte.spec.ts | 131 +++++++++++++++---
.../components/PersonTypeahead.svelte.spec.ts | 40 ++++--
.../routes/briefwechsel/page.server.spec.ts | 60 +++++++-
.../routes/documents/new/page.svelte.spec.ts | 8 +-
frontend/src/routes/page.svelte.spec.ts | 18 ++-
.../src/routes/persons/page.svelte.spec.ts | 1 +
7 files changed, 219 insertions(+), 45 deletions(-)
diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts
index 7265a9a9..aeda6bbe 100644
--- a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts
+++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts
@@ -5,10 +5,10 @@ import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
afterEach(cleanup);
-const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' };
+const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller', displayName: 'Karl Müller' };
const receivers = [
- { id: 'r1', firstName: 'Anna', lastName: 'Schmidt' },
- { id: 'r2', firstName: 'Hans', lastName: 'Weber' }
+ { id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
+ { id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' }
];
const tags = [
{ id: 't1', name: 'Familienbrief' },
diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
index 1a132943..08c2ba28 100644
--- a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
+++ b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
@@ -7,9 +7,21 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
- { id: '1', firstName: 'Max', lastName: 'Mustermann' },
- { id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
- { id: '3', firstName: 'Karl', lastName: 'König' }
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ },
+ {
+ id: '2',
+ firstName: 'Anna',
+ lastName: 'Musterfrau',
+ displayName: 'Anna Musterfrau',
+ personType: 'PERSON'
+ },
+ { id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
];
function mockFetch(persons = PERSONS) {
@@ -45,8 +57,20 @@ describe('PersonMultiSelect – rendering', () => {
it('renders pre-selected persons as chips', async () => {
render(PersonMultiSelect, {
selectedPersons: [
- { id: '1', firstName: 'Max', lastName: 'Mustermann' },
- { id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ },
+ {
+ id: '2',
+ firstName: 'Anna',
+ lastName: 'Musterfrau',
+ displayName: 'Anna Musterfrau',
+ personType: 'PERSON'
+ }
]
});
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
@@ -57,8 +81,20 @@ describe('PersonMultiSelect – rendering', () => {
it('renders hidden inputs for each selected person', async () => {
render(PersonMultiSelect, {
selectedPersons: [
- { id: '1', firstName: 'Max', lastName: 'Mustermann' },
- { id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ },
+ {
+ id: '2',
+ firstName: 'Anna',
+ lastName: 'Musterfrau',
+ displayName: 'Anna Musterfrau',
+ personType: 'PERSON'
+ }
]
});
await tick();
@@ -70,7 +106,15 @@ describe('PersonMultiSelect – rendering', () => {
it('hides the placeholder when persons are selected', async () => {
render(PersonMultiSelect, {
- selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
+ selectedPersons: [
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ }
+ ]
});
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
});
@@ -85,7 +129,7 @@ describe('PersonMultiSelect – selecting persons', () => {
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
- await page.getByText('Mustermann, Max').click();
+ await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({
@@ -100,11 +144,11 @@ describe('PersonMultiSelect – selecting persons', () => {
await input.fill('Mu');
await waitForDebounce();
- await page.getByText('Mustermann, Max').click();
+ await page.getByText('Max Mustermann').click();
await input.fill('Mu');
await waitForDebounce();
- await page.getByText('Musterfrau, Anna').click();
+ await page.getByText('Anna Musterfrau').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
@@ -116,22 +160,41 @@ describe('PersonMultiSelect – selecting persons', () => {
it('filters already-selected persons from search results', async () => {
mockFetch();
render(PersonMultiSelect, {
- selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
+ selectedPersons: [
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ }
+ ]
});
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
- await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
- await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
+ // Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
+ await expect
+ .element(page.getByRole('button', { name: 'Max Mustermann' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
});
it('selects a result with Enter key', async () => {
- mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
+ mockFetch([
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ }
+ ]);
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Ma');
await waitForDebounce();
- await page.getByText('Mustermann, Max').click();
+ await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
});
@@ -142,8 +205,20 @@ describe('PersonMultiSelect – removing persons', () => {
it('removes a chip when its × button is clicked', async () => {
render(PersonMultiSelect, {
selectedPersons: [
- { id: '1', firstName: 'Max', lastName: 'Mustermann' },
- { id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ },
+ {
+ id: '2',
+ firstName: 'Anna',
+ lastName: 'Musterfrau',
+ displayName: 'Anna Musterfrau',
+ personType: 'PERSON'
+ }
]
});
// Buttons have aria-label="Entfernen"
@@ -156,8 +231,20 @@ describe('PersonMultiSelect – removing persons', () => {
it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, {
selectedPersons: [
- { id: '1', firstName: 'Max', lastName: 'Mustermann' },
- { id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ },
+ {
+ id: '2',
+ firstName: 'Anna',
+ lastName: 'Musterfrau',
+ displayName: 'Anna Musterfrau',
+ personType: 'PERSON'
+ }
]
});
await page.getByRole('button', { name: 'Entfernen' }).first().click();
@@ -177,9 +264,9 @@ describe('PersonMultiSelect – click outside', () => {
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
- await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
+ await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click();
await tick();
- await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
index 440fb9cb..4ec6a142 100644
--- a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
+++ b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
@@ -7,8 +7,20 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
- { id: '1', firstName: 'Max', lastName: 'Mustermann' },
- { id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
+ {
+ id: '1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON'
+ },
+ {
+ id: '2',
+ firstName: 'Anna',
+ lastName: 'Musterfrau',
+ displayName: 'Anna Musterfrau',
+ personType: 'PERSON'
+ }
];
function mockFetchWithPersons(persons = PERSONS) {
@@ -76,8 +88,8 @@ describe('PersonTypeahead – search', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
- await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
- await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
+ await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
+ await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
});
@@ -105,7 +117,7 @@ describe('PersonTypeahead – search', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
- await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});
@@ -122,7 +134,7 @@ describe('PersonTypeahead – selection', () => {
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect
- .element(page.getByRole('button', { name: 'Mustermann, Max' }))
+ .element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -152,7 +164,15 @@ describe('PersonTypeahead – selection', () => {
});
it('selects a result with Enter key', async () => {
- mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
+ 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');
@@ -218,7 +238,7 @@ describe('PersonTypeahead – correspondent mode', () => {
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
- await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
+ await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
it('uses correspondents endpoint with q param when typing', async () => {
@@ -259,9 +279,9 @@ describe('PersonTypeahead – click outside', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
- await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
+ await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click();
await tick();
- await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
+ await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});
diff --git a/frontend/src/routes/briefwechsel/page.server.spec.ts b/frontend/src/routes/briefwechsel/page.server.spec.ts
index f8dab9ee..151436b6 100644
--- a/frontend/src/routes/briefwechsel/page.server.spec.ts
+++ b/frontend/src/routes/briefwechsel/page.server.spec.ts
@@ -54,7 +54,15 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
const docs = [{ id: 'd1', title: 'Testbrief' }];
const GET = mockApi([
{ ok: true, data: docs },
- { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
+ {
+ ok: true,
+ data: {
+ firstName: 'Hans',
+ lastName: 'Müller',
+ displayName: 'Hans Müller',
+ personType: 'PERSON'
+ }
+ }
]);
const result = await load({
@@ -76,8 +84,24 @@ describe('korrespondenz load — senderId and receiverId set', () => {
it('calls conversation, sender person, and receiver person endpoints', async () => {
const GET = mockApi([
{ ok: true, data: [] },
- { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
- { ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
+ {
+ ok: true,
+ data: {
+ firstName: 'Hans',
+ lastName: 'Müller',
+ displayName: 'Hans Müller',
+ personType: 'PERSON'
+ }
+ },
+ {
+ ok: true,
+ data: {
+ firstName: 'Anna',
+ lastName: 'Schmidt',
+ displayName: 'Anna Schmidt',
+ personType: 'PERSON'
+ }
+ }
]);
const result = await load({
@@ -98,7 +122,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite true from WRITE_ALL permission', async () => {
mockApi([
{ ok: true, data: [] },
- { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
+ {
+ ok: true,
+ data: {
+ firstName: 'Hans',
+ lastName: 'Müller',
+ displayName: 'Hans Müller',
+ personType: 'PERSON'
+ }
+ }
]);
const result = await load({
@@ -113,7 +145,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite false when user lacks WRITE_ALL', async () => {
mockApi([
{ ok: true, data: [] },
- { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
+ {
+ ok: true,
+ data: {
+ firstName: 'Hans',
+ lastName: 'Müller',
+ displayName: 'Hans Müller',
+ personType: 'PERSON'
+ }
+ }
]);
const result = await load({
@@ -132,7 +172,15 @@ describe('korrespondenz load — backend error', () => {
it('throws when the conversation endpoint returns non-ok', async () => {
mockApi([
{ ok: false, status: 500 },
- { ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
+ {
+ ok: true,
+ data: {
+ firstName: 'Hans',
+ lastName: 'Müller',
+ displayName: 'Hans Müller',
+ personType: 'PERSON'
+ }
+ }
]);
await expect(
diff --git a/frontend/src/routes/documents/new/page.svelte.spec.ts b/frontend/src/routes/documents/new/page.svelte.spec.ts
index dcc3b387..dcdfbdef 100644
--- a/frontend/src/routes/documents/new/page.svelte.spec.ts
+++ b/frontend/src/routes/documents/new/page.svelte.spec.ts
@@ -58,7 +58,9 @@ describe('New document page – receiver prefill', () => {
it('shows a receiver chip when initialReceivers has a person', async () => {
const data = {
...baseData,
- initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
+ initialReceivers: [
+ { id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
+ ]
};
render(Page, { data, form: null });
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
@@ -67,7 +69,9 @@ describe('New document page – receiver prefill', () => {
it('renders a hidden receiverIds input for the prefilled receiver', async () => {
const data = {
...baseData,
- initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
+ initialReceivers: [
+ { id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
+ ]
};
render(Page, { data, form: null });
const hidden = document.querySelector('input[name="receiverIds"]');
diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts
index a13c3822..28f58d3f 100644
--- a/frontend/src/routes/page.svelte.spec.ts
+++ b/frontend/src/routes/page.svelte.spec.ts
@@ -39,8 +39,22 @@ const makeDoc = (overrides = {}) => ({
status: 'UPLOADED' as const,
documentDate: '2024-03-15',
location: 'Berlin',
- sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
- receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
+ sender: {
+ id: 'p1',
+ firstName: 'Max',
+ lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
+ personType: 'PERSON' as const
+ },
+ receivers: [
+ {
+ id: 'p2',
+ firstName: 'Anna',
+ lastName: 'Musterfrau',
+ displayName: 'Anna Musterfrau',
+ personType: 'PERSON' as const
+ }
+ ],
tags: [{ id: 't1', name: 'Familie' }],
filePath: '/files/testbrief.pdf',
createdAt: '2024-03-15T10:00:00Z',
diff --git a/frontend/src/routes/persons/page.svelte.spec.ts b/frontend/src/routes/persons/page.svelte.spec.ts
index 113df55d..6c975a4e 100644
--- a/frontend/src/routes/persons/page.svelte.spec.ts
+++ b/frontend/src/routes/persons/page.svelte.spec.ts
@@ -11,6 +11,7 @@ const makePerson = (overrides = {}) => ({
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
+ displayName: 'Max Mustermann',
documentCount: 0,
...overrides
});
--
2.49.1
From c49cb345ca02323d9552cb88f49d107fb7607291 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:51:32 +0200
Subject: [PATCH 11/25] feat(parser): widen GEB_PATTERN and extract maiden name
in stripMaidenName
Widen pattern from `\s+geb\.\s+\S+` to `,?\s*geb\.?\s+(.+)$` to
handle: optional comma, optional dot, multi-word maiden names.
stripMaidenName() now captures the maiden name instead of discarding
it. Handles all 5 input variants from the ODS data.
Co-Authored-By: Claude Sonnet 4.6
---
.../service/PersonNameParser.java | 13 ++--
.../service/PersonNameParserTest.java | 64 ++++++++++++++++++-
2 files changed, 72 insertions(+), 5 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
index d66db4fd..d33cd75f 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
@@ -18,7 +18,7 @@ public class PersonNameParser {
static final List KNOWN_LAST_NAMES = List.of(
"de Gruyter", "Dieckmann", "Gruber", "Müller", "Wolff", "Cram");
- private static final Pattern GEB_PATTERN = Pattern.compile("\\s+geb\\.\\s+\\S+");
+ private static final Pattern GEB_PATTERN = Pattern.compile(",?\\s*geb\\.?\\s+(.+)$");
private static final Pattern PAREN_LAST_NAME = Pattern.compile("\\(([^)]+)\\)\\s*$");
private static final Pattern MULTI_SEPARATOR = Pattern.compile("\\s+(?:und|u)\\s+");
private static final Pattern SLASH_SEPARATOR = Pattern.compile("//");
@@ -153,10 +153,15 @@ public class PersonNameParser {
);
}
- /** Strips "geb. Xxx" maiden-name annotations. Pass-through until #209. */
+ /** Strips geb annotations and extracts the maiden name. */
public static MaidenNameResult stripMaidenName(String input) {
- String cleaned = GEB_PATTERN.matcher(input).replaceAll("").trim();
- return new MaidenNameResult(cleaned, null);
+ Matcher m = GEB_PATTERN.matcher(input);
+ if (m.find()) {
+ String cleaned = input.substring(0, m.start()).trim();
+ String maidenName = m.group(1).trim();
+ return new MaidenNameResult(cleaned, maidenName);
+ }
+ return new MaidenNameResult(input, null);
}
/** Normalizes dot-compressed names: "Dr.Fr.Zarncke" → "Dr. Fr. Zarncke" */
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index a1148fa7..650e9dc9 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -115,7 +115,7 @@ class PersonNameParserTest {
assertThat(result.title()).isNull();
assertThat(result.firstName()).isEqualTo("Eugenie");
assertThat(result.lastName()).isEqualTo("de Gruyter");
- assertThat(result.maidenName()).isNull();
+ assertThat(result.maidenName()).isEqualTo("Müller");
assertThat(result.annotation()).isNull();
}
@@ -282,6 +282,68 @@ class PersonNameParserTest {
assertThat(result.title()).isNull();
}
+ // --- stripMaidenName — maiden name extraction ---
+
+ @Test
+ void stripMaidenName_standardDot_singleWord() {
+ PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Eugenie de Gruyter geb. Muller");
+ assertThat(result.cleaned()).isEqualTo("Eugenie de Gruyter");
+ assertThat(result.maidenName()).isEqualTo("Muller");
+ }
+
+ @Test
+ void stripMaidenName_dot_multiWordMaidenName() {
+ PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Clara Cram geb. de Gruyter");
+ assertThat(result.cleaned()).isEqualTo("Clara Cram");
+ assertThat(result.maidenName()).isEqualTo("de Gruyter");
+ }
+
+ @Test
+ void stripMaidenName_commaPrefix_noDot_multiWord() {
+ PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Ella Dieckmann, geb de Gruyter");
+ assertThat(result.cleaned()).isEqualTo("Ella Dieckmann");
+ assertThat(result.maidenName()).isEqualTo("de Gruyter");
+ }
+
+ @Test
+ void stripMaidenName_noDot_singleWord() {
+ PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elise Rockstroh geb Sintenis");
+ assertThat(result.cleaned()).isEqualTo("Elise Rockstroh");
+ assertThat(result.maidenName()).isEqualTo("Sintenis");
+ }
+
+ @Test
+ void stripMaidenName_noDot_noMarriedLastName() {
+ PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Elisabeth geb Fernow");
+ assertThat(result.cleaned()).isEqualTo("Elisabeth");
+ assertThat(result.maidenName()).isEqualTo("Fernow");
+ }
+
+ @Test
+ void stripMaidenName_noGeb_returnsNullMaidenName() {
+ PersonNameParser.MaidenNameResult result = PersonNameParser.stripMaidenName("Walter de Gruyter");
+ assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
+ assertThat(result.maidenName()).isNull();
+ }
+
+ // --- split — maiden name extraction end-to-end ---
+
+ @Test
+ void split_gebDot_extractsMaidenName() {
+ PersonNameParser.SplitName result = PersonNameParser.split("Eugenie de Gruyter geb. Muller");
+ assertThat(result.firstName()).isEqualTo("Eugenie");
+ assertThat(result.lastName()).isEqualTo("de Gruyter");
+ assertThat(result.maidenName()).isEqualTo("Muller");
+ }
+
+ @Test
+ void split_gebNoDot_multiWordMaidenName() {
+ PersonNameParser.SplitName result = PersonNameParser.split("Clara Cram geb. de Gruyter");
+ assertThat(result.firstName()).isEqualTo("Clara");
+ assertThat(result.lastName()).isEqualTo("Cram");
+ assertThat(result.maidenName()).isEqualTo("de Gruyter");
+ }
+
// --- enum values ---
@Test
--
2.49.1
From 8421d45c71fc4c68a6b8e8618982bb174b1f72cb Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:53:03 +0200
Subject: [PATCH 12/25] test(parser): add parseReceivers tests for widened geb
pattern
Verify comma-prefix, no-dot, and multi-word maiden name variants
are correctly stripped in parseReceivers().
Co-Authored-By: Claude Sonnet 4.6
---
.../familienarchiv/service/PersonNameParserTest.java | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index 650e9dc9..243d3a51 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -24,6 +24,18 @@ class PersonNameParserTest {
.containsExactly("Eugenie de Gruyter");
}
+ @Test
+ void gebAnnotation_noDot_multiWord_stripped() {
+ assertThat(PersonNameParser.parseReceivers("Ella Dieckmann, geb de Gruyter"))
+ .containsExactly("Ella Dieckmann");
+ }
+
+ @Test
+ void gebAnnotation_noDot_singleWord_stripped() {
+ assertThat(PersonNameParser.parseReceivers("Elise Rockstroh geb Sintenis"))
+ .containsExactly("Elise Rockstroh");
+ }
+
@Test
void twoFirstNames_sharedKnownLastName_und() {
assertThat(PersonNameParser.parseReceivers("Walter und Eugenie de Gruyter"))
--
2.49.1
From 9f90cc1a5f67d927fc200c5f8e6ed013361b7fdf Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:55:50 +0200
Subject: [PATCH 13/25] feat(service): create MAIDEN_NAME alias in
findOrCreateByAlias
When split() returns a non-null maidenName, PersonService now
creates a PersonNameAlias with type MAIDEN_NAME. The maiden name
is stored as lastName on the alias (no firstName).
Co-Authored-By: Claude Sonnet 4.6
---
.../familienarchiv/service/PersonService.java | 13 ++++++++-
.../service/PersonServiceTest.java | 28 +++++++++++++++++++
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
index 51bebc24..41607df5 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
@@ -11,6 +11,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonNameAlias;
+import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -62,11 +63,21 @@ public class PersonService {
String alias = rawName.trim();
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
PersonNameParser.SplitName split = PersonNameParser.split(alias);
- return personRepository.save(Person.builder()
+ Person person = personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
+ if (split.maidenName() != null) {
+ int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
+ aliasRepository.save(PersonNameAlias.builder()
+ .person(person)
+ .lastName(split.maidenName())
+ .type(PersonNameAliasType.MAIDEN_NAME)
+ .sortOrder(nextSortOrder)
+ .build());
+ }
+ return person;
});
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
index 33288bf6..e6ee4a0a 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
@@ -201,6 +201,34 @@ class PersonServiceTest {
verify(personRepository).save(any());
}
+ @Test
+ void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
+ String alias = "Clara Cram geb. de Gruyter";
+ Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
+ when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
+ when(personRepository.save(any())).thenReturn(saved);
+ when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
+ when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ personService.findOrCreateByAlias(alias);
+
+ verify(aliasRepository).save(argThat(a ->
+ a.getLastName().equals("de Gruyter") &&
+ a.getType() == PersonNameAliasType.MAIDEN_NAME));
+ }
+
+ @Test
+ void findOrCreateByAlias_noAlias_whenNoGeb() {
+ String alias = "Clara Cram";
+ Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
+ when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
+ when(personRepository.save(any())).thenReturn(saved);
+
+ personService.findOrCreateByAlias(alias);
+
+ verify(aliasRepository, never()).save(any());
+ }
+
@Test
void findOrCreateByAlias_trimsInput() {
String alias = " Clara Cram ";
--
2.49.1
From e696e5056daae37a25feb0ab180ecbc9f0f423a7 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 12:58:02 +0200
Subject: [PATCH 14/25] feat(parser): implement stripAnnotation for
parenthesized content
Extract trailing (...) content as annotation. Handles birth years
(*1871), nicknames (Tuttu), uncertainty markers (?), and uncertain
names (Quast ?) where the name part is extracted back into the
cleaned result. Uses [^)]* regex to prevent ReDoS.
Co-Authored-By: Claude Sonnet 4.6
---
.../service/PersonNameParser.java | 21 ++++++-
.../service/PersonNameParserTest.java | 55 ++++++++++++++++++-
2 files changed, 73 insertions(+), 3 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
index d33cd75f..ed65346e 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
@@ -172,9 +172,26 @@ public class PersonNameParser {
return input;
}
- /** Strips parenthesized annotations. Pass-through until #210. */
+ private static final Pattern PAREN_ANNOTATION = Pattern.compile("\\s*\\(([^)]*)\\)\\s*$");
+ private static final Pattern UNCERTAIN_NAME = Pattern.compile("^(\\S+)\\s+\\?\\s*$");
+
+ /** Strips trailing parenthesized annotations and extracts the content. */
public static AnnotationResult stripAnnotation(String input) {
- return new AnnotationResult(input, null);
+ Matcher m = PAREN_ANNOTATION.matcher(input);
+ if (!m.find()) {
+ return new AnnotationResult(input, null);
+ }
+ String cleaned = input.substring(0, m.start()).trim();
+ String rawAnnotation = m.group(1).trim();
+
+ Matcher uncertainMatcher = UNCERTAIN_NAME.matcher(rawAnnotation);
+ if (uncertainMatcher.matches()) {
+ String nameFromAnnotation = uncertainMatcher.group(1);
+ cleaned = (cleaned + " " + nameFromAnnotation).trim();
+ return new AnnotationResult(cleaned, "?");
+ }
+
+ return new AnnotationResult(cleaned, rawAnnotation);
}
/** Strips title prefixes. Pass-through until #212. */
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index 243d3a51..756b268b 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -281,12 +281,65 @@ class PersonNameParserTest {
}
@Test
- void stripAnnotation_isPassthrough() {
+ void stripAnnotation_noParens_returnsNull() {
PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Walter de Gruyter");
assertThat(result.cleaned()).isEqualTo("Walter de Gruyter");
assertThat(result.annotation()).isNull();
}
+ @Test
+ void stripAnnotation_birthYear_noSpace() {
+ PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Clara de Gruyter(*1871)");
+ assertThat(result.cleaned()).isEqualTo("Clara de Gruyter");
+ assertThat(result.annotation()).isEqualTo("*1871");
+ }
+
+ @Test
+ void stripAnnotation_uncertainty_withSpace() {
+ PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Ernst Kurmany (?)");
+ assertThat(result.cleaned()).isEqualTo("Ernst Kurmany");
+ assertThat(result.annotation()).isEqualTo("?");
+ }
+
+ @Test
+ void stripAnnotation_nickname_noSpace() {
+ PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Gertrud D.(Tuttu)");
+ assertThat(result.cleaned()).isEqualTo("Gertrud D.");
+ assertThat(result.annotation()).isEqualTo("Tuttu");
+ }
+
+ @Test
+ void stripAnnotation_uncertainName_extractsNameBack() {
+ PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("Richard (Quast ? )");
+ assertThat(result.cleaned()).isEqualTo("Richard Quast");
+ assertThat(result.annotation()).isEqualTo("?");
+ }
+
+ @Test
+ void stripAnnotation_onlyParen_returnsPlaceholder() {
+ PersonNameParser.AnnotationResult result = PersonNameParser.stripAnnotation("(OnlyParen)");
+ assertThat(result.cleaned()).isEmpty();
+ assertThat(result.annotation()).isEqualTo("OnlyParen");
+ }
+
+ // --- split — annotation extraction end-to-end ---
+
+ @Test
+ void split_birthYearAnnotation_extracted() {
+ PersonNameParser.SplitName result = PersonNameParser.split("Clara de Gruyter(*1871)");
+ assertThat(result.firstName()).isEqualTo("Clara");
+ assertThat(result.lastName()).isEqualTo("de Gruyter");
+ assertThat(result.annotation()).isEqualTo("*1871");
+ }
+
+ @Test
+ void split_uncertainName_extractsLastName() {
+ PersonNameParser.SplitName result = PersonNameParser.split("Richard (Quast ? )");
+ assertThat(result.firstName()).isEqualTo("Richard");
+ assertThat(result.lastName()).isEqualTo("Quast");
+ assertThat(result.annotation()).isEqualTo("?");
+ }
+
@Test
void stripTitle_isPassthrough() {
PersonNameParser.TitleResult result = PersonNameParser.stripTitle("Walter de Gruyter");
--
2.49.1
From e49ae5de2907191d2783ba8488460761ae685fa7 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 13:00:34 +0200
Subject: [PATCH 15/25] fix(parser): preserve annotation parens for
single-person inputs
Move paren extraction in parseReceivers() after the multi-separator
check so single-person entries like "Clara de Gruyter(*1871)" keep
their parens intact for split()'s annotation extraction. Multi-person
entries like "Hedi und Tutu (Gruber)" still use parens as shared
last-name override.
Co-Authored-By: Claude Sonnet 4.6
---
.../familienarchiv/service/PersonNameParser.java | 14 ++++++++------
.../service/PersonNameParserTest.java | 12 ++++++++++++
2 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
index ed65346e..f06f4f61 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonNameParser.java
@@ -59,7 +59,14 @@ public class PersonNameParser {
// 1. Strip "geb. Xxx" maiden-name annotations
String cleaned = GEB_PATTERN.matcher(raw).replaceAll("").trim();
- // 2. Extract parenthesised last name override, e.g. "(Gruber)"
+ // 2. If no multi-separator present, this is a single person — leave parens
+ // intact for split()'s annotation extraction
+ if (!MULTI_SEPARATOR.matcher(cleaned).find()) {
+ return List.of(cleaned);
+ }
+
+ // 3. Extract parenthesised last name override, e.g. "(Gruber)"
+ // Only applies to multi-person entries like "Hedi und Tutu (Gruber)"
String sharedLastName = null;
Matcher parenMatcher = PAREN_LAST_NAME.matcher(cleaned);
if (parenMatcher.find()) {
@@ -67,11 +74,6 @@ public class PersonNameParser {
cleaned = cleaned.substring(0, parenMatcher.start()).trim();
}
- // 3. If no multi-separator present, this is a single person
- if (!MULTI_SEPARATOR.matcher(cleaned).find()) {
- return List.of(cleaned);
- }
-
// 4. Split on " und " / " u "
String[] parts = MULTI_SEPARATOR.split(cleaned);
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
index 756b268b..2158032a 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonNameParserTest.java
@@ -24,6 +24,18 @@ class PersonNameParserTest {
.containsExactly("Eugenie de Gruyter");
}
+ @Test
+ void singlePerson_annotationParenPreserved() {
+ assertThat(PersonNameParser.parseReceivers("Clara de Gruyter(*1871)"))
+ .containsExactly("Clara de Gruyter(*1871)");
+ }
+
+ @Test
+ void singlePerson_nicknameParenPreserved() {
+ assertThat(PersonNameParser.parseReceivers("Gertrud D.(Tuttu)"))
+ .containsExactly("Gertrud D.(Tuttu)");
+ }
+
@Test
void gebAnnotation_noDot_multiWord_stripped() {
assertThat(PersonNameParser.parseReceivers("Ella Dieckmann, geb de Gruyter"))
--
2.49.1
From 68f0c4c4b9f9916ce98dc85b6dcdd6d79160168d Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 13:03:53 +0200
Subject: [PATCH 16/25] feat(service): add PersonTypeClassifier with keyword
heuristics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Static classify() method uses position-aware keyword matching:
- SKIP: Briefumschlag, Kondolenzbriefe, Hochzeitsgedicht (start)
- INSTITUTION: Firma, Architekt (start), GmbH, Co (end)
- GROUP: Familie, Comité, Comite, Geschwister, Gesellschafter,
Garde, Mitarbeiter (start), Eltern, Kinder,
Schwiegereltern (word boundary)
- PERSON: default for all other inputs
Case-insensitive. 25 parameterized test cases.
Co-Authored-By: Claude Sonnet 4.6
---
.../service/PersonTypeClassifier.java | 60 +++++++++++++
.../service/PersonTypeClassifierTest.java | 89 +++++++++++++++++++
2 files changed, 149 insertions(+)
create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/PersonTypeClassifier.java
create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/PersonTypeClassifierTest.java
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonTypeClassifier.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonTypeClassifier.java
new file mode 100644
index 00000000..e39abb05
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonTypeClassifier.java
@@ -0,0 +1,60 @@
+package org.raddatz.familienarchiv.service;
+
+import java.util.List;
+import org.raddatz.familienarchiv.model.PersonType;
+
+public class PersonTypeClassifier {
+
+ private static final List SKIP_KEYWORDS = List.of(
+ "Briefumschlag", "Kondolenzbriefe", "Hochzeitsgedicht");
+
+ private static final List INSTITUTION_START = List.of(
+ "Firma", "Architekt");
+
+ private static final List INSTITUTION_END = List.of(
+ "GmbH");
+
+ private static final List GROUP_START = List.of(
+ "Familie", "Comité", "Comite", "Geschwister", "Gesellschafter",
+ "Garde", "Mitarbeiter");
+
+ private static final List GROUP_CONTAINS = List.of(
+ "Eltern", "Kinder", "Schwiegereltern");
+
+ public static PersonType classify(String rawName) {
+ if (rawName == null || rawName.isBlank()) return PersonType.PERSON;
+
+ String trimmed = rawName.trim();
+ String lower = trimmed.toLowerCase();
+
+ for (String keyword : SKIP_KEYWORDS) {
+ if (lower.startsWith(keyword.toLowerCase())) return PersonType.SKIP;
+ }
+
+ for (String keyword : INSTITUTION_START) {
+ if (lower.startsWith(keyword.toLowerCase())) return PersonType.INSTITUTION;
+ }
+ for (String keyword : INSTITUTION_END) {
+ if (lower.endsWith(keyword.toLowerCase())) return PersonType.INSTITUTION;
+ }
+ if (lower.endsWith(" co") || lower.endsWith(" co.")) return PersonType.INSTITUTION;
+
+ for (String keyword : GROUP_START) {
+ if (lower.startsWith(keyword.toLowerCase())) return PersonType.GROUP;
+ }
+ for (String keyword : GROUP_CONTAINS) {
+ if (containsWord(lower, keyword.toLowerCase())) return PersonType.GROUP;
+ }
+
+ return PersonType.PERSON;
+ }
+
+ private static boolean containsWord(String text, String word) {
+ int idx = text.indexOf(word);
+ if (idx < 0) return false;
+ boolean startOk = idx == 0 || !Character.isLetter(text.charAt(idx - 1));
+ int end = idx + word.length();
+ boolean endOk = end >= text.length() || !Character.isLetter(text.charAt(end));
+ return startOk && endOk;
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonTypeClassifierTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonTypeClassifierTest.java
new file mode 100644
index 00000000..ed9132ef
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonTypeClassifierTest.java
@@ -0,0 +1,89 @@
+package org.raddatz.familienarchiv.service;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.raddatz.familienarchiv.model.PersonType;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PersonTypeClassifierTest {
+
+ // --- SKIP ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "'Briefumschlag aus Java', SKIP",
+ "'Kondolenzbriefe zum Tod von Walter de Gruyter', SKIP",
+ "'Hochzeitsgedicht fur Paul u Luise de Gruyter', SKIP"
+ })
+ void classify_skipEntries(String input, PersonType expected) {
+ assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
+ }
+
+ // --- INSTITUTION ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "'Arthur Collignon GmbH', INSTITUTION",
+ "'Firma Auschrath', INSTITUTION",
+ "'Westermann u Co', INSTITUTION",
+ "'Architekt Korschelt u Renker', INSTITUTION"
+ })
+ void classify_institutionEntries(String input, PersonType expected) {
+ assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
+ }
+
+ // --- GROUP ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "'Comite der Abschiedsfeier', GROUP",
+ "'Comité zur Errichtung eines Heine-Denkmals', GROUP",
+ "'Garde du Corps', GROUP",
+ "'Geschwister de Gruyter', GROUP",
+ "'Gesellschafter des Verlages', GROUP",
+ "'Ella de Gruyters Eltern', GROUP",
+ "'Eugenie de Gruyters Kinder', GROUP",
+ "'Hilde de Gruyters Schwiegereltern', GROUP",
+ "'Eltern Muller', GROUP",
+ "'Familie Cram', GROUP",
+ "'Familie Hasenvlever', GROUP",
+ "'Mitarbeiter Verlag', GROUP",
+ "'Mitarbeiter Druckerei TrebbinClara Cram', GROUP",
+ "'Mitarbeiter Kunstverlag Mu', GROUP"
+ })
+ void classify_groupEntries(String input, PersonType expected) {
+ assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
+ }
+
+ // --- PERSON (default) ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "'Walter de Gruyter', PERSON",
+ "'Clara Cram', PERSON",
+ "'Eugenie de Gruyter geb. Müller', PERSON",
+ "'Dr. Firma Mueller', PERSON"
+ })
+ void classify_personEntries(String input, PersonType expected) {
+ assertThat(PersonTypeClassifier.classify(input)).isEqualTo(expected);
+ }
+
+ // --- Edge cases ---
+
+ @Test
+ void classify_null_returnsPerson() {
+ assertThat(PersonTypeClassifier.classify(null)).isEqualTo(PersonType.PERSON);
+ }
+
+ @Test
+ void classify_blank_returnsPerson() {
+ assertThat(PersonTypeClassifier.classify(" ")).isEqualTo(PersonType.PERSON);
+ }
+
+ @Test
+ void classify_caseInsensitive() {
+ assertThat(PersonTypeClassifier.classify("firma auschrath")).isEqualTo(PersonType.INSTITUTION);
+ }
+}
--
2.49.1
From a3da5731d0bc2c8476580c007872616259c3fd1b Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 13:06:49 +0200
Subject: [PATCH 17/25] feat(service): integrate PersonTypeClassifier into
findOrCreateByAlias
Classify raw name before processing. SKIP returns null (no Person
created). INSTITUTION/GROUP skip split() and store full name in
lastName with firstName=null and appropriate personType.
Co-Authored-By: Claude Sonnet 4.6
---
.../familienarchiv/service/PersonService.java | 12 ++++++
.../service/PersonServiceTest.java | 42 +++++++++++++++++++
2 files changed, 54 insertions(+)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
index 41607df5..590f10b9 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
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.http.HttpStatus;
@@ -61,7 +62,18 @@ public class PersonService {
@Transactional
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();
+ PersonType type = PersonTypeClassifier.classify(alias);
+ if (type == PersonType.SKIP) return null;
+
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
+ if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
+ return personRepository.save(Person.builder()
+ .alias(alias)
+ .lastName(alias)
+ .personType(type)
+ .build());
+ }
+
PersonNameParser.SplitName split = PersonNameParser.split(alias);
Person person = personRepository.save(Person.builder()
.alias(alias)
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
index e6ee4a0a..1095e93f 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Person;
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.web.server.ResponseStatusException;
@@ -217,6 +218,47 @@ class PersonServiceTest {
a.getType() == PersonNameAliasType.MAIDEN_NAME));
}
+ @Test
+ void findOrCreateByAlias_returnsNull_whenSkip() {
+ Person result = personService.findOrCreateByAlias("Briefumschlag aus Java");
+ assertThat(result).isNull();
+ verify(personRepository, never()).save(any());
+ }
+
+ @Test
+ void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
+ String alias = "Arthur Collignon GmbH";
+ when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
+ when(personRepository.save(any())).thenAnswer(inv -> {
+ Person p = inv.getArgument(0);
+ p.setId(UUID.randomUUID());
+ return p;
+ });
+
+ Person result = personService.findOrCreateByAlias(alias);
+
+ assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
+ assertThat(result.getFirstName()).isNull();
+ assertThat(result.getLastName()).isEqualTo("Arthur Collignon GmbH");
+ }
+
+ @Test
+ void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
+ String alias = "Geschwister de Gruyter";
+ when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
+ when(personRepository.save(any())).thenAnswer(inv -> {
+ Person p = inv.getArgument(0);
+ p.setId(UUID.randomUUID());
+ return p;
+ });
+
+ Person result = personService.findOrCreateByAlias(alias);
+
+ assertThat(result.getPersonType()).isEqualTo(PersonType.GROUP);
+ assertThat(result.getFirstName()).isNull();
+ assertThat(result.getLastName()).isEqualTo("Geschwister de Gruyter");
+ }
+
@Test
void findOrCreateByAlias_noAlias_whenNoGeb() {
String alias = "Clara Cram";
--
2.49.1
From 6ee1ef73c38607b228ebc3ff675b8626843f4d6f Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 13:09:16 +0200
Subject: [PATCH 18/25] feat(ui): add PersonTypeBadge to person list and detail
pages
Show colored badge for non-PERSON types per design spec:
- INSTITUTION: blue with building icon
- GROUP: purple with people icon
- UNKNOWN: amber with question mark icon
- PERSON: no badge (unmarked default)
Badge appears on person cards in list and on detail page.
Co-Authored-By: Claude Sonnet 4.6
---
.../src/lib/components/PersonTypeBadge.svelte | 50 +++++++++++++++++++
frontend/src/routes/persons/+page.svelte | 5 ++
.../src/routes/persons/[id]/PersonCard.svelte | 8 +++
3 files changed, 63 insertions(+)
create mode 100644 frontend/src/lib/components/PersonTypeBadge.svelte
diff --git a/frontend/src/lib/components/PersonTypeBadge.svelte b/frontend/src/lib/components/PersonTypeBadge.svelte
new file mode 100644
index 00000000..2bfd1a39
--- /dev/null
+++ b/frontend/src/lib/components/PersonTypeBadge.svelte
@@ -0,0 +1,50 @@
+
+
+{#if personType === 'INSTITUTION'}
+
+
+ {m.person_type_INSTITUTION()}
+
+{:else if personType === 'GROUP'}
+
+
+ {m.person_type_GROUP()}
+
+{:else if personType === 'UNKNOWN'}
+
+
+ {m.person_type_UNKNOWN()}
+
+{/if}
diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte
index 9487c82f..9ba5380d 100644
--- a/frontend/src/routes/persons/+page.svelte
+++ b/frontend/src/routes/persons/+page.svelte
@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
+import PersonTypeBadge from '$lib/components/PersonTypeBadge.svelte';
import PersonsStatsBar from './PersonsStatsBar.svelte';
import PersonsEmptyState from './PersonsEmptyState.svelte';
@@ -107,6 +108,10 @@ function handleSearch() {
{person.displayName}
+ {#if person.personType && person.personType !== 'PERSON'}
+
+ {/if}
+
{#if person.alias}
„{person.alias}"
diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte b/frontend/src/routes/persons/[id]/PersonCard.svelte
index 639d58ad..10094d87 100644
--- a/frontend/src/routes/persons/[id]/PersonCard.svelte
+++ b/frontend/src/routes/persons/[id]/PersonCard.svelte
@@ -1,6 +1,7 @@
-{#if personType === 'INSTITUTION'}
+{#if config}
-
- {m.person_type_INSTITUTION()}
-
-{:else if personType === 'GROUP'}
-
-
- {m.person_type_GROUP()}
-
-{:else if personType === 'UNKNOWN'}
-
-
- {m.person_type_UNKNOWN()}
+ {#if config.icon === 'building'}
+
+ {:else if config.icon === 'people'}
+
+ {:else}
+
+ {/if}
+ {config.label}
{/if}
+
+
diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css
index 197aef4e..c7c1bb46 100644
--- a/frontend/src/routes/layout.css
+++ b/frontend/src/routes/layout.css
@@ -106,6 +106,19 @@
--c-pdf-bg: #ebebeb;
--c-pdf-ctrl: #d8d8d8;
--c-pdf-text: #333333;
+
+ /* PersonType badge — institution (navy-tinted blue) */
+ --c-badge-institution-bg: #e8eff7;
+ --c-badge-institution-text: #1a4971;
+ --c-badge-institution-border: #c4d5e8;
+ /* PersonType badge — group (muted purple) */
+ --c-badge-group-bg: #f0e8f5;
+ --c-badge-group-text: #5a2d6f;
+ --c-badge-group-border: #d8c5e3;
+ /* PersonType badge — unknown (amber warning) */
+ --c-badge-unknown-bg: #fdf4e3;
+ --c-badge-unknown-text: #7a5a0a;
+ --c-badge-unknown-border: #f0ddb3;
}
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
@@ -148,6 +161,16 @@
--c-pdf-bg: #010e1e;
--c-pdf-ctrl: #011526;
--c-pdf-text: #f0efe9;
+
+ --c-badge-institution-bg: rgba(30, 80, 140, 0.25);
+ --c-badge-institution-text: #8bb8e0;
+ --c-badge-institution-border: rgba(30, 80, 140, 0.4);
+ --c-badge-group-bg: rgba(90, 45, 111, 0.25);
+ --c-badge-group-text: #c9a0dc;
+ --c-badge-group-border: rgba(90, 45, 111, 0.4);
+ --c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
+ --c-badge-unknown-text: #e0c060;
+ --c-badge-unknown-border: rgba(122, 90, 10, 0.4);
}
}
@@ -186,6 +209,16 @@
--c-pdf-bg: #010e1e;
--c-pdf-ctrl: #011526;
--c-pdf-text: #f0efe9;
+
+ --c-badge-institution-bg: rgba(30, 80, 140, 0.25);
+ --c-badge-institution-text: #8bb8e0;
+ --c-badge-institution-border: rgba(30, 80, 140, 0.4);
+ --c-badge-group-bg: rgba(90, 45, 111, 0.25);
+ --c-badge-group-text: #c9a0dc;
+ --c-badge-group-border: rgba(90, 45, 111, 0.4);
+ --c-badge-unknown-bg: rgba(122, 90, 10, 0.25);
+ --c-badge-unknown-text: #e0c060;
+ --c-badge-unknown-border: rgba(122, 90, 10, 0.4);
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as
──── */
--
2.49.1
From 166f60f7d359bc82ad26bf1014998a06cc62d47e Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 18:36:34 +0200
Subject: [PATCH 24/25] feat(ui): show type icon in avatar for non-person
entities
Person list and detail page avatars now display a type-specific
icon (building, people group, question mark) instead of meaningless
initials for INSTITUTION, GROUP, and UNKNOWN person types.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/routes/persons/+page.svelte | 32 ++++++++++++++++++-
.../src/routes/persons/[id]/PersonCard.svelte | 32 ++++++++++++++++++-
2 files changed, 62 insertions(+), 2 deletions(-)
diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte
index 9ba5380d..a9fc76a9 100644
--- a/frontend/src/routes/persons/+page.svelte
+++ b/frontend/src/routes/persons/+page.svelte
@@ -100,7 +100,37 @@ function handleSearch() {
- {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
+ {#if person.personType && person.personType !== 'PERSON'}
+
+ {:else}
+ {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
+ {/if}
diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte b/frontend/src/routes/persons/[id]/PersonCard.svelte
index 10094d87..81665515 100644
--- a/frontend/src/routes/persons/[id]/PersonCard.svelte
+++ b/frontend/src/routes/persons/[id]/PersonCard.svelte
@@ -32,7 +32,37 @@ let {
- {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
+ {#if person.personType && person.personType !== 'PERSON'}
+
+ {:else}
+ {person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
+ {/if}
--
2.49.1
From c34db997fa0237ae4848d7a45a6d00061a458763 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Wed, 8 Apr 2026 18:38:33 +0200
Subject: [PATCH 25/25] feat(model): add title field to PersonUpdateDTO with
@Size validation
Add title to PersonUpdateDTO with @Size(max=50) constraint.
PersonService.createPerson and updatePerson now handle the title
field with blank-to-null normalization.
Co-Authored-By: Claude Sonnet 4.6
---
.../java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java | 2 ++
.../java/org/raddatz/familienarchiv/service/PersonService.java | 2 ++
2 files changed, 4 insertions(+)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java
index ff00f1e0..b7026c70 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java
@@ -5,6 +5,8 @@ import lombok.Data;
@Data
public class PersonUpdateDTO {
+ @Size(max = 50)
+ private String title;
@Size(max = 100)
private String firstName;
@Size(max = 100)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
index aa6c49f8..e900dd4c 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
@@ -111,6 +111,7 @@ public class PersonService {
public Person createPerson(PersonUpdateDTO dto) {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
+ .title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
@@ -138,6 +139,7 @@ public class PersonService {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
+ person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
--
2.49.1