diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
index 44cf88ec..0739cbfb 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
@@ -21,6 +21,13 @@ import java.util.UUID;
*
Type field: {@code null} for {@link Kind#LETTER} entries; frontend must not render
* an event-type badge for letters.
*
+ *
Root-tag fields ({@code rootTagId}/{@code rootTagName}/{@code rootTagColor}): the
+ * letter's primary root tag — the root ancestor of its alphabetically-first assigned tag (#835).
+ * All three are {@code null} for non-{@link Kind#LETTER} entries and for letters with no tags;
+ * {@code rootTagColor} is additionally {@code null} when the resolved root carries no color token.
+ * They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
+ * types stay optional.
+ *
*
Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/
@@ -37,6 +44,9 @@ public record TimelineEntryDTO(
UUID eventId,
UUID documentId,
List linkedPersonIds,
- DerivedEventType derivedType
+ DerivedEventType derivedType,
+ UUID rootTagId,
+ String rootTagName,
+ String rootTagColor
) {
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
index 75803bb0..28147de9 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
@@ -266,7 +266,8 @@ public class TimelineEventService {
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
p.getBirthDate(), null,
p.getDisplayName(), EventType.PERSONAL,
- null, null, List.of(p.getId()), DerivedEventType.BIRTH))
+ null, null, List.of(p.getId()), DerivedEventType.BIRTH,
+ null, null, null))
.toList();
}
@@ -277,7 +278,8 @@ public class TimelineEventService {
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
p.getDeathDate(), null,
p.getDisplayName(), EventType.PERSONAL,
- null, null, List.of(p.getId()), DerivedEventType.DEATH))
+ null, null, List.of(p.getId()), DerivedEventType.DEATH,
+ null, null, null))
.toList();
}
@@ -303,7 +305,8 @@ public class TimelineEventService {
title, EventType.PERSONAL,
null, null,
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
- DerivedEventType.MARRIAGE));
+ DerivedEventType.MARRIAGE,
+ null, null, null));
}
}
return result;
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
index f55ccd91..12fe681b 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
@@ -10,6 +10,9 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
+import org.raddatz.familienarchiv.tag.RootTag;
+import org.raddatz.familienarchiv.tag.Tag;
+import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -52,6 +55,7 @@ public class TimelineService {
private final TimelineEventService timelineEventService;
private final DocumentService documentService;
private final PersonService personService;
+ private final TagService tagService;
/**
* Assembles the timeline for the given filter. All filters are ANDed.
@@ -95,11 +99,15 @@ public class TimelineService {
}
// ── letters ─────────────────────────────────────────────────────────
- List docs = fetchDocuments(filter.personId());
- for (Document doc : docs) {
+ List letters = new ArrayList<>();
+ for (Document doc : fetchDocuments(filter.personId())) {
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
- entries.add(mapDocument(doc));
+ letters.add(doc);
+ }
+ Map rootByPrimaryTagId = resolveLetterRootTags(letters);
+ for (Document doc : letters) {
+ entries.add(mapDocument(doc, rootByPrimaryTagId));
}
return bucket(entries);
@@ -217,11 +225,15 @@ public class TimelineService {
ev.getId(),
null,
personIds,
+ null,
+ null,
+ null,
null
);
}
- private TimelineEntryDTO mapDocument(Document doc) {
+ private TimelineEntryDTO mapDocument(Document doc, Map rootByPrimaryTagId) {
+ RootTag root = resolvePrimaryRoot(doc, rootByPrimaryTagId);
return new TimelineEntryDTO(
Kind.LETTER,
doc.getMetaDatePrecision(),
@@ -235,10 +247,42 @@ public class TimelineService {
null,
doc.getId(),
List.of(),
- null
+ null,
+ root == null ? null : root.id(),
+ root == null ? null : root.name(),
+ root == null ? null : root.color()
);
}
+ /**
+ * Resolves the root tags for the letters' primary tags in one batched pass — no per-letter
+ * N+1: each letter contributes only its alphabetically-first assigned tag (#835), and
+ * {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag.
+ */
+ private Map resolveLetterRootTags(List letters) {
+ List primaryTags = letters.stream()
+ .map(TimelineService::primaryTag)
+ .filter(t -> t != null)
+ .toList();
+ if (primaryTags.isEmpty()) return Map.of();
+ return tagService.resolveRootTags(primaryTags);
+ }
+
+ private RootTag resolvePrimaryRoot(Document doc, Map rootByPrimaryTagId) {
+ Tag primary = primaryTag(doc);
+ return primary == null ? null : rootByPrimaryTagId.get(primary.getId());
+ }
+
+ /** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */
+ private static Tag primaryTag(Document doc) {
+ Set tags = doc.getTags();
+ if (tags == null || tags.isEmpty()) return null;
+ return tags.stream()
+ .filter(t -> t.getName() != null)
+ .min(Comparator.comparing(Tag::getName))
+ .orElse(null);
+ }
+
private String resolveSenderName(Document doc) {
if (doc.getSender() != null) return doc.getSender().getDisplayName();
String text = doc.getSenderText();
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
index 549c4f10..06255ecb 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
@@ -11,13 +11,19 @@ import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
+import org.raddatz.familienarchiv.tag.RootTag;
+import org.raddatz.familienarchiv.tag.Tag;
+import org.raddatz.familienarchiv.tag.TagService;
import java.time.LocalDate;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@@ -27,6 +33,7 @@ class TimelineServiceTest {
@Mock TimelineEventService timelineEventService;
@Mock DocumentService documentService;
@Mock PersonService personService;
+ @Mock TagService tagService;
@InjectMocks TimelineService timelineService;
@@ -61,9 +68,11 @@ class TimelineServiceTest {
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
- LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null);
+ LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
+ null, null, null);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
- LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null);
+ LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
+ null, null, null);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
@@ -423,13 +432,98 @@ class TimelineServiceTest {
// ─── Helpers ─────────────────────────────────────────────────────────────
+ // ─── root-tag chip enrichment (#835) ─────────────────────────────────────
+
+ @Test
+ void letter_with_tags_carries_its_primary_root_tag() {
+ // REQ-003/006: the primary tag is the root ancestor of the alphabetically-first
+ // assigned tag ("Briefe von der Front" < "Zeitung"), resolved to root "Krieg".
+ UUID kriegId = UUID.randomUUID();
+ Tag front = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(kriegId).build();
+ Tag zeitung = Tag.builder().id(UUID.randomUUID()).name("Zeitung").build();
+ Document doc = docWithTags(LocalDate.of(1916, 5, 1), DatePrecision.MONTH, front, zeitung);
+ when(eventRepository.findAll()).thenReturn(List.of());
+ when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
+ when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
+ when(tagService.resolveRootTags(anyList()))
+ .thenReturn(Map.of(front.getId(), new RootTag(kriegId, "Krieg", "sienna")));
+
+ TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
+
+ assertThat(entry.rootTagId()).isEqualTo(kriegId);
+ assertThat(entry.rootTagName()).isEqualTo("Krieg");
+ assertThat(entry.rootTagColor()).isEqualTo("sienna");
+ }
+
+ @Test
+ void untagged_letter_has_no_root_tag_fields() {
+ // REQ-005: a letter with no tags carries null id/name/color — and never hits TagService.
+ Document doc = docWithDate(LocalDate.of(1909, 3, 1), DatePrecision.MONTH);
+ when(eventRepository.findAll()).thenReturn(List.of());
+ when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
+ when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
+
+ TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
+
+ assertThat(entry.rootTagId()).isNull();
+ assertThat(entry.rootTagName()).isNull();
+ assertThat(entry.rootTagColor()).isNull();
+ verify(tagService, never()).resolveRootTags(anyList());
+ }
+
+ @Test
+ void letter_primary_root_without_color_yields_null_color() {
+ // REQ-007: a colorless root → rootTagColor null, id+name still present (neutral chip).
+ UUID rootId = UUID.randomUUID();
+ Tag allgemein = Tag.builder().id(rootId).name("Allgemein").build();
+ Document doc = docWithTags(LocalDate.of(1910, 2, 1), DatePrecision.MONTH, allgemein);
+ when(eventRepository.findAll()).thenReturn(List.of());
+ when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
+ when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
+ when(tagService.resolveRootTags(anyList()))
+ .thenReturn(Map.of(rootId, new RootTag(rootId, "Allgemein", null)));
+
+ TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
+
+ assertThat(entry.rootTagId()).isEqualTo(rootId);
+ assertThat(entry.rootTagName()).isEqualTo("Allgemein");
+ assertThat(entry.rootTagColor()).isNull();
+ }
+
+ @Test
+ void root_tags_resolved_in_a_single_batched_pass() {
+ // REQ-004: many letters → exactly one resolveRootTags call (no per-letter N+1).
+ UUID kriegId = UUID.randomUUID();
+ Tag krieg = Tag.builder().id(kriegId).name("Krieg").color("sienna").build();
+ Tag weihnachten = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").color("amber").build();
+ Document a = docWithTags(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, krieg);
+ Document b = docWithTags(LocalDate.of(1916, 12, 1), DatePrecision.MONTH, weihnachten);
+ Document c = docWithTags(LocalDate.of(1917, 1, 1), DatePrecision.YEAR, krieg);
+ when(eventRepository.findAll()).thenReturn(List.of());
+ when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
+ when(documentService.getAllForTimeline()).thenReturn(List.of(a, b, c));
+ when(tagService.resolveRootTags(anyList())).thenReturn(Map.of(
+ kriegId, new RootTag(kriegId, "Krieg", "sienna"),
+ weihnachten.getId(), new RootTag(weihnachten.getId(), "Weihnachten", "amber")));
+
+ timelineService.assemble(noFilters());
+
+ verify(tagService, times(1)).resolveRootTags(anyList());
+ }
+
+ private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
+ assertThat(result.years()).hasSize(1);
+ return result.years().get(0).entries().get(0);
+ }
+
private static TimelineFilter noFilters() {
return new TimelineFilter(null, null, null, null, null);
}
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
- date, null, title, null, null, UUID.randomUUID(), List.of(), null);
+ date, null, title, null, null, UUID.randomUUID(), List.of(), null,
+ null, null, null);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {
@@ -437,6 +531,12 @@ class TimelineServiceTest {
.metaDatePrecision(precision).documentDate(date).build();
}
+ private static Document docWithTags(LocalDate date, DatePrecision precision, Tag... tags) {
+ return Document.builder().id(UUID.randomUUID()).title("Brief")
+ .metaDatePrecision(precision).documentDate(date)
+ .tags(new HashSet<>(Set.of(tags))).build();
+ }
+
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
return Document.builder().id(UUID.randomUUID()).title(title)
.metaDatePrecision(precision).documentDate(date).build();