Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4859c77964 | ||
|
|
bbf2f96e28 | ||
|
|
8376a520c5 | ||
|
|
c19d4be3fe | ||
|
|
90e2b4d6c2 | ||
|
|
d33c1e5249 | ||
|
|
1114676ae3 | ||
|
|
0be0a524b3 |
@@ -139,3 +139,18 @@
|
||||
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
|
||||
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
|
||||
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |
|
||||
| REQ-001 | TimelineEntryDTO carries rootTagId/rootTagName/rootTagColor for LETTER entries, assembled in-transaction (id+name+token only) | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `timeline/TimelineService#mapDocument` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag` | Done |
|
||||
| REQ-002 | the three root-tag fields are nullable and not `@Schema(requiredMode = REQUIRED)` | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `frontend/src/lib/generated/api.ts` (optional) | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields` (+ regenerated `api.ts` shows `rootTag*?`) | Done |
|
||||
| REQ-003 | primary tag = root ancestor of the alphabetically-first assigned tag, resolved via TagService | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `TagServiceTest#resolveRootTags_walksChildToRoot_withRootColor`, `TagServiceIntegrationTest#resolveRootTags_walksPersistedChainToRoot_withRootColor` | Done |
|
||||
| REQ-004 | roots resolved in a single batched/memoized pass (≤ M findAncestorIds, no per-letter N+1); color from the root token | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#resolveLetterRootTags` | `TagServiceTest#resolveRootTags_memoizesPerDistinctTag_noNPlusOne`, `TimelineServiceTest#root_tags_resolved_in_a_single_batched_pass` | Done |
|
||||
| REQ-005 | a letter with no tags → all three fields null; LetterCard renders no chip | #835 | zeitstrahl-tag-chips | `timeline/TimelineService#mapDocument`, `frontend/src/lib/timeline/LetterCard.svelte` | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields`, `LetterCard.svelte.spec.ts#renders no chip when the letter has no root tag` | Done |
|
||||
| REQ-006 | a letter with multiple tags → exactly one primary root (deterministic) | #835 | zeitstrahl-tag-chips | `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `LetterCard.svelte.spec.ts#renders one root-tag chip beneath the meta line` | Done |
|
||||
| REQ-007 | a colorless root → rootTagColor null; frontend renders a neutral chip, no `var(--c-tag-)` | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `frontend/src/lib/timeline/TagChip.svelte` | `TagServiceTest#resolveRootTags_returnsNullColor_whenRootHasNoColor`, `TimelineServiceTest#letter_primary_root_without_color_yields_null_color`, `TagChip.svelte.spec.ts#renders a neutral chip with no --c-tag- binding when color is null` | Done |
|
||||
| REQ-008 | LetterCard with a rootTagName renders one §3 chip beneath the meta line | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte`, `LetterCard.svelte` | `TagChip.svelte.spec.ts#renders the tag name`, `LetterCard.svelte.spec.ts#renders one root-tag chip beneath the meta line` | Done |
|
||||
| REQ-008a | a long name truncates with ellipsis, no horizontal overflow at 320px; full name in the chip title | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte`, `LetterCard.svelte` | `LetterCard.svelte.spec.ts#keeps a long tag name from overflowing the card at 320px`, `TagChip.svelte.spec.ts#exposes the full name as the chip title` | Done |
|
||||
| REQ-009 | chip color applied via `var(--c-tag-{token})`, no raw hex | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#applies the color via var(--c-tag-{token}), never raw hex` | Done |
|
||||
| REQ-010 | rootTagName rendered via `{...}` escaping; no `{@html}` in lib/timeline | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `grep -rn '@html' frontend/src/lib/timeline/` → zero | Done |
|
||||
| REQ-011 | colored square aria-hidden; sr-only theme label prefix so color is never the only cue | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#prefixes the name with an sr-only theme label and a decorative square` | Done |
|
||||
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
|
||||
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
|
||||
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.tag;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The root-ancestor view of a tag: its id, display name, and color token.
|
||||
* Colors are stored only on root tags, so {@code color} is the authoritative token
|
||||
* (one of {@link TagService#ALLOWED_TAG_COLORS}) or {@code null} when the root has none.
|
||||
* Returned by {@link TagService#resolveRootTags} for read surfaces (the timeline chip,
|
||||
* later the Thema buckets) that need a tag's theme without the entity graph.
|
||||
*/
|
||||
public record RootTag(UUID id, String name, String color) {
|
||||
}
|
||||
@@ -175,6 +175,54 @@ public class TagService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves each given tag to its root ancestor, returning a {@link RootTag} (id, name, color
|
||||
* token) keyed by the input tag's id. A root tag maps to itself; a child is walked to the
|
||||
* ancestor with no parent via {@link TagRepository#findAncestorIds} (one CTE per distinct
|
||||
* non-root tag, memoized) plus a single batched {@code findAllById}, so a timeline of many
|
||||
* letters sharing few tags costs O(distinct tags) queries, never O(letters). The color comes
|
||||
* from the resolved root's stored token (null when the root has none). Safe on detached tags.
|
||||
*/
|
||||
public Map<UUID, RootTag> resolveRootTags(Collection<Tag> tags) {
|
||||
if (tags == null || tags.isEmpty()) return Map.of();
|
||||
|
||||
Map<UUID, Tag> distinct = new LinkedHashMap<>();
|
||||
for (Tag tag : tags) {
|
||||
if (tag != null && tag.getId() != null) distinct.putIfAbsent(tag.getId(), tag);
|
||||
}
|
||||
|
||||
Map<UUID, List<UUID>> ancestorIdsByTagId = new HashMap<>();
|
||||
Set<UUID> idsToLoad = new HashSet<>();
|
||||
for (Tag tag : distinct.values()) {
|
||||
if (tag.getParentId() == null) continue;
|
||||
List<UUID> ancestorIds = tagRepository.findAncestorIds(tag.getId());
|
||||
ancestorIdsByTagId.put(tag.getId(), ancestorIds);
|
||||
idsToLoad.addAll(ancestorIds);
|
||||
}
|
||||
|
||||
Map<UUID, Tag> ancestorsById = idsToLoad.isEmpty() ? Map.of()
|
||||
: tagRepository.findAllById(idsToLoad).stream()
|
||||
.collect(Collectors.toMap(Tag::getId, t -> t));
|
||||
|
||||
Map<UUID, RootTag> result = new HashMap<>();
|
||||
for (Tag tag : distinct.values()) {
|
||||
Tag root = resolveRoot(tag, ancestorIdsByTagId.get(tag.getId()), ancestorsById);
|
||||
result.put(tag.getId(), new RootTag(root.getId(), root.getName(), root.getColor()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Tag resolveRoot(Tag tag, List<UUID> ancestorIds, Map<UUID, Tag> ancestorsById) {
|
||||
if (tag.getParentId() == null) return tag;
|
||||
if (ancestorIds != null) {
|
||||
for (UUID ancestorId : ancestorIds) {
|
||||
Tag ancestor = ancestorsById.get(ancestorId);
|
||||
if (ancestor != null && ancestor.getParentId() == null) return ancestor;
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each tag name, returns the set of that tag's ID plus all descendant IDs.
|
||||
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.
|
||||
|
||||
@@ -21,6 +21,13 @@ import java.util.UUID;
|
||||
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
|
||||
* an event-type badge for letters.
|
||||
*
|
||||
* <p><b>Root-tag fields ({@code rootTagId}/{@code rootTagName}/{@code rootTagColor}):</b> 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.
|
||||
*
|
||||
* <p>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<UUID> linkedPersonIds,
|
||||
DerivedEventType derivedType
|
||||
DerivedEventType derivedType,
|
||||
UUID rootTagId,
|
||||
String rootTagName,
|
||||
String rootTagColor
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Document> docs = fetchDocuments(filter.personId());
|
||||
for (Document doc : docs) {
|
||||
List<Document> 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<UUID, RootTag> 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<UUID, RootTag> 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<UUID, RootTag> resolveLetterRootTags(List<Document> letters) {
|
||||
List<Tag> 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<UUID, RootTag> 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<Tag> 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();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.raddatz.familienarchiv.tag;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Real-Postgres proof that {@link TagService#resolveRootTags} walks a persisted tag chain to its
|
||||
* true root through the recursive-CTE {@link TagRepository#findAncestorIds}. The CTE cannot run on
|
||||
* H2, so this uses {@code postgres:16-alpine} via Testcontainers. Exhaustive case coverage lives in
|
||||
* {@link TagServiceTest} (mocked); this pins the DB-dependent ancestry walk (issue #835, REQ-003/004).
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TagServiceIntegrationTest {
|
||||
|
||||
@Autowired private TagRepository tagRepository;
|
||||
private TagService tagService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tagService = new TagService(tagRepository);
|
||||
}
|
||||
|
||||
private Tag tag(String name, String color, UUID parentId) {
|
||||
return tagRepository.save(Tag.builder().name(name).color(color).parentId(parentId).build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRootTags_walksPersistedChainToRoot_withRootColor() {
|
||||
// leaf → mid → root resolves to the root's (id, name, color) via the real recursive CTE.
|
||||
Tag root = tag("Krieg", "sienna", null);
|
||||
Tag mid = tag("Feldpost", null, root.getId());
|
||||
Tag leaf = tag("Briefe von der Front", null, mid.getId());
|
||||
|
||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(leaf));
|
||||
|
||||
assertThat(result.get(leaf.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRootTags_returnsRootItself_forPersistedRoot() {
|
||||
Tag root = tag("Weihnachten", "amber", null);
|
||||
|
||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
|
||||
|
||||
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Weihnachten", "amber"));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -450,6 +451,74 @@ class TagServiceTest {
|
||||
assertThat(child2.getColor()).isEqualTo("sienna");
|
||||
}
|
||||
|
||||
// ─── resolveRootTags ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void resolveRootTags_returnsTagItself_whenTagIsRoot() {
|
||||
// REQ-003/004: a root tag (no parent) is its own primary root — no ancestry walk, no load.
|
||||
Tag root = Tag.builder().id(UUID.randomUUID()).name("Krieg").color("sienna").build();
|
||||
|
||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
|
||||
|
||||
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
|
||||
verify(tagRepository, never()).findAncestorIds(any());
|
||||
verify(tagRepository, never()).findAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRootTags_walksChildToRoot_withRootColor() {
|
||||
// REQ-003/004: a nested child resolves to its root's id/name/color via one CTE + one batch.
|
||||
UUID rootId = UUID.randomUUID();
|
||||
UUID midId = UUID.randomUUID();
|
||||
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
|
||||
Tag mid = Tag.builder().id(midId).name("Feldpost").parentId(rootId).build();
|
||||
Tag child = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(midId).build();
|
||||
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(midId, rootId));
|
||||
when(tagRepository.findAllById(Set.of(midId, rootId))).thenReturn(List.of(mid, rootTag));
|
||||
|
||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
|
||||
|
||||
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRootTags_memoizesPerDistinctTag_noNPlusOne() {
|
||||
// REQ-004: two letters sharing one tag id ⇒ a single findAncestorIds + a single batch load.
|
||||
UUID rootId = UUID.randomUUID();
|
||||
UUID childId = UUID.randomUUID();
|
||||
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
|
||||
Tag childA = Tag.builder().id(childId).name("Front").parentId(rootId).build();
|
||||
Tag childB = Tag.builder().id(childId).name("Front").parentId(rootId).build();
|
||||
when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId));
|
||||
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
|
||||
|
||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(childA, childB));
|
||||
|
||||
assertThat(result.get(childId)).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
|
||||
verify(tagRepository, times(1)).findAncestorIds(childId);
|
||||
verify(tagRepository, times(1)).findAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRootTags_returnsNullColor_whenRootHasNoColor() {
|
||||
// REQ-007: a colorless root yields RootTag.color() == null (frontend renders a neutral chip).
|
||||
UUID rootId = UUID.randomUUID();
|
||||
Tag rootTag = Tag.builder().id(rootId).name("Allgemein").build();
|
||||
Tag child = Tag.builder().id(UUID.randomUUID()).name("Notiz").parentId(rootId).build();
|
||||
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(rootId));
|
||||
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
|
||||
|
||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
|
||||
|
||||
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Allgemein", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRootTags_returnsEmptyMap_forEmptyInput() {
|
||||
assertThat(tagService.resolveRootTags(List.of())).isEmpty();
|
||||
verify(tagRepository, never()).findAncestorIds(any());
|
||||
}
|
||||
|
||||
// ─── mergeTags ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1050,6 +1050,7 @@
|
||||
"timeline_provenance_derived": "abgeleitet",
|
||||
"timeline_provenance_curated": "kuratiert",
|
||||
"timeline_letter_glyph_label": "Brief",
|
||||
"timeline_tag_chip_label": "Thema",
|
||||
"timeline_layer_historical_suffix": "historisch",
|
||||
"timeline_strip_density_caption": "Monats-Dichte",
|
||||
"timeline_events_count": "{count} Ereignisse",
|
||||
|
||||
@@ -1050,6 +1050,7 @@
|
||||
"timeline_provenance_derived": "derived",
|
||||
"timeline_provenance_curated": "curated",
|
||||
"timeline_letter_glyph_label": "Letter",
|
||||
"timeline_tag_chip_label": "Topic",
|
||||
"timeline_layer_historical_suffix": "historical",
|
||||
"timeline_strip_density_caption": "Monthly density",
|
||||
"timeline_events_count": "{count} events",
|
||||
|
||||
@@ -1050,6 +1050,7 @@
|
||||
"timeline_provenance_derived": "derivado",
|
||||
"timeline_provenance_curated": "curado",
|
||||
"timeline_letter_glyph_label": "Carta",
|
||||
"timeline_tag_chip_label": "Tema",
|
||||
"timeline_layer_historical_suffix": "histórico",
|
||||
"timeline_strip_density_caption": "Densidad mensual",
|
||||
"timeline_events_count": "{count} eventos",
|
||||
|
||||
@@ -2455,6 +2455,10 @@ export interface components {
|
||||
linkedPersonIds?: string[];
|
||||
/** @enum {string} */
|
||||
derivedType?: "BIRTH" | "DEATH" | "MARRIAGE";
|
||||
/** Format: uuid */
|
||||
rootTagId?: string;
|
||||
rootTagName?: string;
|
||||
rootTagColor?: string;
|
||||
};
|
||||
TimelineYearDTO: {
|
||||
/** Format: int32 */
|
||||
|
||||
@@ -90,4 +90,12 @@ describe('message key parity', () => {
|
||||
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
// #835 REQ-013: the letter chip's sr-only theme label is a Paraglide key in every
|
||||
// locale so color is never the only cue; the tag NAME is rendered as data, not translated.
|
||||
it('zeitstrahl tag-chip label key is present in all locales (#835 REQ-013)', () => {
|
||||
expect(de).toMatchObject({ timeline_tag_chip_label: 'Thema' });
|
||||
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
|
||||
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import GlyphLabel from './GlyphLabel.svelte';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
@@ -46,4 +47,9 @@ const receiver = $derived(
|
||||
<span data-testid="letter-date"> · {dateLabel}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if entry.rootTagName}
|
||||
<!-- The primary root-tag chip sits on its own line beneath the meta line
|
||||
(#835 §3); absent when the letter has no tag (REQ-005). -->
|
||||
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
@@ -86,4 +88,42 @@ describe('LetterCard', () => {
|
||||
expect(document.body.textContent).toContain(evil);
|
||||
expect(document.querySelector('a script')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders one root-tag chip beneath the meta line when rootTagName is present (REQ-008)', () => {
|
||||
render(LetterCard, { entry: makeEntry({ rootTagName: 'Familie', rootTagColor: 'sage' }) });
|
||||
const chips = document.querySelectorAll('[data-testid="tag-chip"]');
|
||||
expect(chips).toHaveLength(1);
|
||||
expect(chips[0].textContent).toContain('Familie');
|
||||
});
|
||||
|
||||
it('renders no chip when the letter has no root tag (REQ-005/006)', () => {
|
||||
render(LetterCard, { entry: makeEntry({ rootTagName: undefined, rootTagColor: undefined }) });
|
||||
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a long tag name from overflowing the card at 320px, full name in the title (REQ-008a)', () => {
|
||||
document.body.style.width = '320px';
|
||||
render(LetterCard, {
|
||||
entry: makeEntry({
|
||||
rootTagName: 'Briefe von der Front und aus der Heimat',
|
||||
rootTagColor: 'sienna'
|
||||
})
|
||||
});
|
||||
const link = document.querySelector('a') as HTMLAnchorElement;
|
||||
expect(link.scrollWidth).toBeLessThanOrEqual(link.clientWidth);
|
||||
const chip = document.querySelector('[data-testid="tag-chip"]') as HTMLElement;
|
||||
expect(chip.getAttribute('title')).toBe('Briefe von der Front und aus der Heimat');
|
||||
document.body.style.width = '';
|
||||
});
|
||||
|
||||
it('renders the chip inside an expanded YearLetterStrip too (REQ-012)', async () => {
|
||||
render(YearLetterStrip, {
|
||||
letters: [makeEntry({ rootTagName: 'Familie', rootTagColor: 'sage', documentId: 'doc-1' })],
|
||||
year: 1909
|
||||
});
|
||||
(document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement).click();
|
||||
await tick();
|
||||
const chip = document.querySelector('[data-testid="tag-chip"]');
|
||||
expect(chip?.textContent).toContain('Familie');
|
||||
});
|
||||
});
|
||||
|
||||
44
frontend/src/lib/timeline/TagChip.svelte
Normal file
44
frontend/src/lib/timeline/TagChip.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
/**
|
||||
* A single root-tag chip on a timeline letter card (§3 of the Zeitstrahl spec): a
|
||||
* decorative colored square marker plus the tag name, prefixed for screen readers by
|
||||
* an sr-only theme label so color is never the only cue (WCAG 1.4.1, REQ-011). The
|
||||
* name is curator/import-derived and rendered via default `{...}` escaping — never the
|
||||
* raw-HTML directive (REQ-010). `color` is a `--c-tag-*` token or null; a null color
|
||||
* renders a neutral marker with no `var(--c-tag-)` reference (REQ-007). Truncation is set inline
|
||||
* (not via a utility class) so a long name ellipsizes even before the stylesheet loads,
|
||||
* keeping the card free of horizontal overflow at 320px (REQ-008a).
|
||||
*/
|
||||
let { name, color }: { name: string; color: string | null } = $props();
|
||||
|
||||
const squareStyle = $derived(color ? `background-color: var(--c-tag-${color})` : '');
|
||||
</script>
|
||||
|
||||
<span
|
||||
data-testid="tag-chip"
|
||||
title={name}
|
||||
style="display: inline-flex; align-items: center; gap: 4px; max-width: 100%; min-width: 0"
|
||||
class="mt-1 self-start rounded-full border border-line bg-surface px-2 py-0.5"
|
||||
>
|
||||
<span class="sr-only">{m.timeline_tag_chip_label()}: </span>
|
||||
{#if color}
|
||||
<span
|
||||
data-testid="tag-chip-square"
|
||||
aria-hidden="true"
|
||||
style={squareStyle}
|
||||
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm"
|
||||
></span>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="tag-chip-square"
|
||||
aria-hidden="true"
|
||||
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm bg-ink-3"
|
||||
></span>
|
||||
{/if}
|
||||
<span
|
||||
style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0"
|
||||
class="truncate font-sans text-[11px] text-ink-2">{name}</span
|
||||
>
|
||||
</span>
|
||||
46
frontend/src/lib/timeline/TagChip.svelte.spec.ts
Normal file
46
frontend/src/lib/timeline/TagChip.svelte.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('TagChip', () => {
|
||||
it('renders the tag name (REQ-008)', () => {
|
||||
render(TagChip, { name: 'Familie', color: 'sage' });
|
||||
expect(document.body.textContent).toContain('Familie');
|
||||
});
|
||||
|
||||
it('prefixes the name with an sr-only theme label and a decorative square (REQ-011)', () => {
|
||||
render(TagChip, { name: 'Krieg', color: 'sienna' });
|
||||
const srOnly = document.querySelector('.sr-only');
|
||||
expect(srOnly?.textContent).toContain(m.timeline_tag_chip_label());
|
||||
const square = document.querySelector('[data-testid="tag-chip-square"]');
|
||||
expect(square?.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('applies the color via var(--c-tag-{token}), never raw hex (REQ-009)', () => {
|
||||
render(TagChip, { name: 'Krieg', color: 'sienna' });
|
||||
const square = document.querySelector('[data-testid="tag-chip-square"]') as HTMLElement;
|
||||
expect(square.getAttribute('style')).toContain('var(--c-tag-sienna)');
|
||||
});
|
||||
|
||||
it('renders a neutral chip with no --c-tag- binding when color is null (REQ-007)', () => {
|
||||
render(TagChip, { name: 'Allgemein', color: null });
|
||||
expect(document.body.textContent).toContain('Allgemein');
|
||||
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
|
||||
});
|
||||
|
||||
it('exposes the full name as the chip title so a truncated name stays reachable (REQ-008a)', () => {
|
||||
render(TagChip, { name: 'Briefe von der Front', color: 'sienna' });
|
||||
const chip = document.querySelector('[data-testid="tag-chip"]') as HTMLElement;
|
||||
expect(chip.getAttribute('title')).toBe('Briefe von der Front');
|
||||
});
|
||||
|
||||
it('renders an HTML-bearing name as inert text, never markup (REQ-010)', () => {
|
||||
const evil = '<img src=x onerror="alert(1)">';
|
||||
render(TagChip, { name: evil, color: null });
|
||||
expect(document.body.textContent).toContain(evil);
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user