feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847
@@ -28,6 +28,13 @@ import java.util.UUID;
|
||||
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||
* types stay optional.
|
||||
*
|
||||
* <p><b>Letter→event link ({@code linkedEventId}):</b> for a {@link Kind#LETTER} entry, the id of
|
||||
* the curated {@link TimelineEvent} whose {@code documents} set contains the letter's document, or
|
||||
* {@code null} when the letter is referenced by no curated event (#827). Computed on read from the
|
||||
* existing {@code timeline_event_documents} join — no new column. {@code null} for non-letter
|
||||
* entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||
* type stays optional.
|
||||
*
|
||||
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
||||
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
||||
*/
|
||||
@@ -47,6 +54,7 @@ public record TimelineEntryDTO(
|
||||
DerivedEventType derivedType,
|
||||
UUID rootTagId,
|
||||
String rootTagName,
|
||||
String rootTagColor
|
||||
String rootTagColor,
|
||||
UUID linkedEventId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ public class TimelineEventService {
|
||||
p.getBirthDate(), null,
|
||||
p.getDisplayName(), EventType.PERSONAL,
|
||||
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
|
||||
null, null, null))
|
||||
null, null, null, null))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ public class TimelineEventService {
|
||||
p.getDeathDate(), null,
|
||||
p.getDisplayName(), EventType.PERSONAL,
|
||||
null, null, List.of(p.getId()), DerivedEventType.DEATH,
|
||||
null, null, null))
|
||||
null, null, null, null))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ public class TimelineEventService {
|
||||
null, null,
|
||||
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
||||
DerivedEventType.MARRIAGE,
|
||||
null, null, null));
|
||||
null, null, null, null));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -80,9 +80,14 @@ public class TimelineService {
|
||||
// Resolve generation person IDs once — used across all three layers
|
||||
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
||||
|
||||
// Fetch curated events once — reused for both the event entries below and the
|
||||
// batched letter→event link resolution (resolveLetterEventLinks), so the
|
||||
// membership pass costs no extra query. REQ-005.
|
||||
List<TimelineEvent> allEvents = eventRepository.findAll();
|
||||
|
||||
// ── curated events ───────────────────────────────────────────────────
|
||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
||||
for (TimelineEvent ev : eventRepository.findAll()) {
|
||||
for (TimelineEvent ev : allEvents) {
|
||||
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
||||
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
||||
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
||||
@@ -107,8 +112,9 @@ public class TimelineService {
|
||||
letters.add(doc);
|
||||
}
|
||||
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
|
||||
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
|
||||
for (Document doc : letters) {
|
||||
entries.add(mapDocument(doc, rootByDocId));
|
||||
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
|
||||
}
|
||||
|
||||
return bucket(entries);
|
||||
@@ -229,11 +235,13 @@ public class TimelineService {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
|
||||
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
|
||||
Map<UUID, UUID> eventByDocId) {
|
||||
RootTag root = rootByDocId.get(doc.getId());
|
||||
return new TimelineEntryDTO(
|
||||
Kind.LETTER,
|
||||
@@ -251,10 +259,38 @@ public class TimelineService {
|
||||
null,
|
||||
root == null ? null : root.id(),
|
||||
root == null ? null : root.name(),
|
||||
root == null ? null : root.color()
|
||||
root == null ? null : root.color(),
|
||||
eventByDocId.get(doc.getId())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the
|
||||
* event whose {@code documents} set contains the letter (REQ-005). A single doc→event map is
|
||||
* built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents}
|
||||
* carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
|
||||
* event, the first by repository iteration order wins ({@code putIfAbsent}). The map is built
|
||||
* from <em>all</em> events (not just the year/type-filtered ones) so the link is a stable
|
||||
* property of the data; the frontend's filter-then-group decides whether the linked event is
|
||||
* actually on screen (#827). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
|
||||
*/
|
||||
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
|
||||
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
|
||||
if (letterDocIds.isEmpty()) return Map.of();
|
||||
|
||||
Map<UUID, UUID> eventByDocId = new HashMap<>();
|
||||
for (TimelineEvent ev : events) {
|
||||
Set<Document> linkedDocs = ev.getDocuments();
|
||||
if (linkedDocs == null) continue;
|
||||
for (Document linked : linkedDocs) {
|
||||
if (letterDocIds.contains(linked.getId())) {
|
||||
eventByDocId.putIfAbsent(linked.getId(), ev.getId());
|
||||
|
|
||||
}
|
||||
}
|
||||
}
|
||||
return eventByDocId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
|
||||
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
|
||||
|
||||
@@ -69,10 +69,10 @@ class TimelineServiceTest {
|
||||
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,
|
||||
null, null, null);
|
||||
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,
|
||||
null, null, null);
|
||||
null, null, null, null);
|
||||
|
||||
var sorted = List.of(e2, e1).stream()
|
||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||
@@ -511,6 +511,44 @@ class TimelineServiceTest {
|
||||
verify(tagService, times(1)).resolveRootTags(anyList());
|
||||
}
|
||||
|
||||
// ─── letter→event link (#827, REQ-005/006) ───────────────────────────────
|
||||
|
||||
@Test
|
||||
void letter_in_a_curated_events_documents_carries_that_events_id() {
|
||||
// REQ-005: linkedEventId = the curated event whose documents set contains the letter.
|
||||
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||
UUID eventId = UUID.randomUUID();
|
||||
TimelineEvent event = TimelineEvent.builder().id(eventId)
|
||||
.title("Briefe von der Front").type(EventType.PERSONAL)
|
||||
.documents(new HashSet<>(Set.of(letterDoc)))
|
||||
.build(); // no eventDate → event lands undated, leaving the year band to the letter
|
||||
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||
|
||||
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||
|
||||
assertThat(entry.linkedEventId()).isEqualTo(eventId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void letter_in_no_curated_event_has_null_linkedEventId() {
|
||||
// REQ-006: a letter referenced by no curated event → linkedEventId null (frontend falls
|
||||
// back to the per-year "Weitere Briefe" bucket).
|
||||
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||
TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
|
||||
.title("Anderes Ereignis").type(EventType.PERSONAL)
|
||||
.documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build())))
|
||||
.build();
|
||||
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||
|
||||
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||
|
||||
assertThat(entry.linkedEventId()).isNull();
|
||||
}
|
||||
|
||||
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
|
||||
assertThat(result.years()).hasSize(1);
|
||||
return result.years().get(0).entries().get(0);
|
||||
@@ -523,7 +561,7 @@ class TimelineServiceTest {
|
||||
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,
|
||||
null, null, null);
|
||||
null, null, null, null);
|
||||
}
|
||||
|
||||
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
||||
|
||||
Reference in New Issue
Block a user
Multi-event letters link to one arbitrary event — nondeterministic, and a filter-then-group hole.
resolveLetterEventLinkspickslinkedEventIdviaputIfAbsentovereventRepository.findAll(), which has noORDER BY. A document is@ManyToManywithTimelineEvent, so a letter can belong to several curated events, yet only the first one Hibernate happens to return wins.ORDER BY, so the winning event — and thus the Ereignis bucket the letter lands in — can flip between page loads for a multi-event document.linkedEventId = E1, misses it ineventLookup, and drops the letter into 'Weitere Briefe' — even though E2 (which also contains it) is on screen. The letter should cluster under E2.Consider an
ORDER BYto at least make the pick deterministic, or carrying all linking event ids so the frontend can choose a surviving one.