From 1114676ae3b2b1bd1cf76f41187e416d50157582 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 14:56:18 +0200 Subject: [PATCH] feat(timeline): carry each letter's primary root tag in the DTO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimelineEntryDTO gains three nullable letter-only fields — rootTagId, rootTagName, rootTagColor (token) — assembled in-transaction in TimelineService (ADR-036): id + name + token only, never a serialized Tag entity. A letter's primary tag is the root ancestor of its alphabetically-first assigned tag (#827 Resolved Decision 3); roots are resolved through TagService in one batched pass over the distinct primary tags (no per-letter N+1). The fields are null for non-letter entries, untagged letters, and (color only) a colorless root, so they are deliberately not @Schema(requiredMode = REQUIRED). Refs #835 Co-Authored-By: Claude Opus 4.8 --- .../timeline/TimelineEntryDTO.java | 12 +- .../timeline/TimelineEventService.java | 9 +- .../timeline/TimelineService.java | 54 ++++++++- .../timeline/TimelineServiceTest.java | 106 +++++++++++++++++- 4 files changed, 169 insertions(+), 12 deletions(-) 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();