diff --git a/.specify/rtm.md b/.specify/rtm.md index 26d9e22a..f37acb48 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -202,7 +202,7 @@ | REQ-006 | letter linked to no curated event → loose chronological letter (alternating; density strip past 12) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#keeps a letter with no linkedEventId loose`; `YearBand.svelte.spec.ts#renders loose (unlinked) letters in a sparse year as alternating .letter-row, no card` | Done | | REQ-007 | loose-letter layout + density strip count ONLY non-event-linked letters; clustered letter never also loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#places each letter in exactly one place`; `YearBand.svelte.spec.ts#counts only loose letters in the density strip; event letters stay in the card` | Done | | REQ-008 | letter whose only linking event is filtered out (absent from lookup) → loose, never re-introduces the event (filter-then-cluster) | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`splitYearLetters` filters via `eventLookup`) | `eventClustering.spec.ts#keeps a letter whose linkedEventId is absent from the lookup loose (filter-then-cluster, REQ-008)` | Done | -| REQ-009 | `TimelineEntryDTO` carries nullable `linkedEventId` for LETTER entries, one batched pass, no new column/migration, not @Schema(REQUIRED) | #850 | inline-event-clustering | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id`, `#letter_in_no_curated_event_has_null_linkedEventId` | Done | +| REQ-009 | `TimelineEntryDTO` carries nullable `linkedEventId` for LETTER entries, one batched pass, no new column/migration, not @Schema(REQUIRED); a multi-event letter links deterministically (earliest date, then id) | #850 | inline-event-clustering | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id`, `#letter_in_no_curated_event_has_null_linkedEventId`, `#multi_event_letter_links_deterministically_to_the_earliest_event` | Done | | REQ-010 | event/letter/sender/receiver text via Svelte `{...}` escaping; no `{@html}` anywhere in `lib/timeline/` (grep gate, CWE-79) | #850 | inline-event-clustering | `frontend/src/lib/timeline/*.svelte` | `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive`; `EventCluster.svelte.spec.ts#renders an HTML-bearing event title verbatim as text, never as markup` | Done | | REQ-011 | wrapping header keeps #842 add-event CTA + #780 filter trigger; meta-line drops the grouping segment | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`, `#drops the letters segment instead of showing "0 Briefe"` (no "Gruppierung") | Done | | REQ-012 | show-more/less labels are new Paraglide keys in de/en/es; unused #827 grouping/Thema keys removed | #850 | inline-event-clustering | `frontend/messages/{de,en,es}.json`, `frontend/src/lib/timeline/EventCluster.svelte` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales` (now asserts `timeline_bucket_show_more`/`_less`; `timeline_grouping_date` removed) | Done | 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 ea79b749..cd918627 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java @@ -269,17 +269,28 @@ public class TimelineService { * event whose {@code documents} set contains the letter (REQ-009). 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 all events (not just the year/type-filtered ones) so the link is a stable - * property of the data; the frontend's filter-then-cluster decides whether the linked event is - * actually on screen (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7). + * event, the earliest-dated event wins (then lowest {@code eventId}); the pass runs over a + * stably-ordered copy so the link is a deterministic property of the data, not a coin-flip on + * the undefined repository iteration order ({@code putIfAbsent} keeps the first = winner). The + * map is built from all events (not just the year/type-filtered ones) so the link is a + * stable property of the data; the frontend's filter-then-cluster decides whether the linked + * event is actually on screen (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7). */ private Map resolveLetterEventLinks(List letters, List events) { Set letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet()); if (letterDocIds.isEmpty()) return Map.of(); + // Stable order so a multi-event letter links deterministically: earliest event date + // (undated last), then lowest id. putIfAbsent below then keeps the winner (REQ-009). + List ordered = events.stream() + .sorted(Comparator + .comparing(TimelineEvent::getEventDate, + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(TimelineEvent::getId)) + .toList(); + Map eventByDocId = new HashMap<>(); - for (TimelineEvent ev : events) { + for (TimelineEvent ev : ordered) { Set linkedDocs = ev.getDocuments(); if (linkedDocs == null) continue; for (Document linked : linkedDocs) { 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 f09bc45a..2409f3ca 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java @@ -549,6 +549,46 @@ class TimelineServiceTest { assertThat(entry.linkedEventId()).isNull(); } + @Test + void multi_event_letter_links_deterministically_to_the_earliest_event() { + // REQ-009: a document referenced by >1 curated event links to the earliest-dated event + // (then lowest id), independent of repository iteration order — not a coin-flip on + // findAll()'s undefined order. + Document shared = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH); + TimelineEvent earlier = TimelineEvent.builder() + .id(UUID.fromString("00000000-0000-0000-0000-000000000001")) + .title("Frühes Ereignis").type(EventType.PERSONAL) + .eventDate(LocalDate.of(1913, 3, 1)).precision(DatePrecision.MONTH) + .documents(new HashSet<>(Set.of(shared))) + .build(); + TimelineEvent later = TimelineEvent.builder() + .id(UUID.fromString("00000000-0000-0000-0000-000000000002")) + .title("Spätes Ereignis").type(EventType.PERSONAL) + .eventDate(LocalDate.of(1914, 3, 1)).precision(DatePrecision.MONTH) + .documents(new HashSet<>(Set.of(shared))) + .build(); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(shared)); + + // `later` first in iteration: a naive putIfAbsent would wrongly pick it. + when(eventRepository.findAll()).thenReturn(List.of(later, earlier)); + assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId()) + .isEqualTo(earlier.getId()); + + // Reversed order yields the same winner — the link is order-independent. + when(eventRepository.findAll()).thenReturn(List.of(earlier, later)); + assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId()) + .isEqualTo(earlier.getId()); + } + + private static TimelineEntryDTO theLetter(TimelineDTO result) { + return java.util.stream.Stream.concat( + result.years().stream().flatMap(y -> y.entries().stream()), + result.undated().stream()) + .filter(e -> e.kind() == Kind.LETTER) + .findFirst().orElseThrow(); + } + private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) { assertThat(result.years()).hasSize(1); return result.years().get(0).entries().get(0);