diff --git a/.specify/rtm.md b/.specify/rtm.md index b7b8e54d..08c560d3 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -194,3 +194,18 @@ | REQ-009 | TimelineView threads canWrite through the year-band path and the undated bucket (identical gate) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `TimelineView.svelte.spec.ts#threads canWrite to a curated event in both a year band and the undated bucket`, `#threads canWrite to a curated HISTORICAL world band in both paths` | Done | | REQ-010 | person add-event opens #781 create form prefilled with the person, returns to /persons/{id} on save | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (#781) | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` (URL), `zeitstrahl/events/new/page.server.spec.ts#redirects to /persons/{id} when originPersonId is a valid UUID` (#781) | Done | | REQ-011 | non-curator direct nav to /zeitstrahl/events/new or /{id}/edit → 403 (existing #781 route guard, regression) | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (#781, unchanged — both share `requireWriteAll`) | `zeitstrahl/events/new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `zeitstrahl/events/[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done | +| REQ-001 | `/zeitstrahl` renders a single chronological timeline with no grouping-mode control (no toggle/Thema/drawer) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/TimelineView.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`; absence of `GroupingControl`/`LetterBucket`/`BucketHeaderChip` (never ported) | Done | +| REQ-002 | curated event with ≥1 same-year linked letter → contained card (event header + glyph/title/date·provenance/edit + compact letter cards) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `EventCluster.svelte.spec.ts#renders a data-testid event-card with the event title once`, `#shows the event-edit link for a curator`, `#renders its letters as compact a.lcard.ev cards`; `YearBand.svelte.spec.ts#renders a curated event with a same-year linked letter as one event-card, title once, no separate pill` | Done | +| REQ-003 | event card body > 5 letters → first 5 + keyboard-operable show-more/less toggle (aria-expanded, ≥44px) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/eventClustering.ts` (`CLUSTER_PREVIEW=5`) | `EventCluster.svelte.spec.ts#shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5`, `#renders no show-more toggle when the cluster holds 5 or fewer letters` | Done | +| REQ-004 | linked letters in a year other than the event's band → labeled ✉ text-header card (no pill, no edit link) per such year | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `EventCluster.svelte.spec.ts#renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given`; `YearBand.svelte.spec.ts#renders a cross-year cluster (event not in this band) as a ✉ text-header card holding it` | Done | +| REQ-005 | event/derived/world-band with no linked letters → existing plain pill/world-band (unchanged) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `YearBand.svelte.spec.ts#renders a curated event with NO linked letters as a plain EventPill, no card` | Done | +| 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); 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 | +| REQ-013 | `GET /api/timeline` failure → existing localized error state via `getErrorMessage(code)` (unchanged #779) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` (unchanged error path) | covered by #779 `zeitstrahl` error-state tests (regression — no change) | Done | +| REQ-014 | HISTORICAL curated event with ≥1 linked letter keeps its full-width WorldBand — never clusters into a card (preserves #779 REQ-009); its letters stay loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`buildEventLookup` excludes HISTORICAL) | `eventClustering.spec.ts#excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)`; `TimelineView.svelte.spec.ts#keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)` | Done | +| REQ-015 | a cross-year ✉ card is placed at its earliest linked letter's chronological position in the band — never appended after later-dated loose letters | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)` | Done | 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 0739cbfb..1d24f3fc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java @@ -28,6 +28,13 @@ import java.util.UUID; * They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript * types stay optional. * + *

Letter→event link ({@code linkedEventId}): 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 (#850). 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. + * *

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 ) { } 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 f2ee6d7e..03092647 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -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; 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 7a084205..75dc381a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java @@ -80,13 +80,20 @@ public class TimelineService { // Resolve generation person IDs once — used across all three layers Set genPersonIds = resolveGenerationPersonIds(filter.generation()); + // Fetch curated events once; the events that survive the filter below feed both the + // event entries and the batched letter→event link pass (resolveLetterEventLinks), so the + // membership pass costs no extra query and touches only on-screen events. REQ-009. + List allEvents = eventRepository.findAll(); + // ── curated events ─────────────────────────────────────────────────── List entries = new ArrayList<>(); - for (TimelineEvent ev : eventRepository.findAll()) { + List filteredEvents = new ArrayList<>(); + for (TimelineEvent ev : allEvents) { if (!passesTypeFilter(ev.getType(), filter.type())) continue; if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue; if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue; if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue; + filteredEvents.add(ev); entries.add(mapEvent(ev)); } @@ -107,8 +114,9 @@ public class TimelineService { letters.add(doc); } Map rootByDocId = resolveLetterRootTags(letters); + Map eventByDocId = resolveLetterEventLinks(letters, filteredEvents); for (Document doc : letters) { - entries.add(mapDocument(doc, rootByDocId)); + entries.add(mapDocument(doc, rootByDocId, eventByDocId)); } return bucket(entries); @@ -229,11 +237,13 @@ public class TimelineService { null, null, null, + null, null ); } - private TimelineEntryDTO mapDocument(Document doc, Map rootByDocId) { + private TimelineEntryDTO mapDocument(Document doc, Map rootByDocId, + Map eventByDocId) { RootTag root = rootByDocId.get(doc.getId()); return new TimelineEntryDTO( Kind.LETTER, @@ -251,10 +261,50 @@ 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-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 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 only over the events that survived the timeline filter, so the lazy + * {@code documents} collection is hydrated for on-screen events alone (finding #10); a letter + * whose only linking event was filtered out links to nothing, matching the frontend's + * filter-then-cluster (#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 : ordered) { + Set 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), 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 06255ecb..b146f66e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java @@ -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,106 @@ class TimelineServiceTest { verify(tagService, times(1)).resolveRootTags(anyList()); } + // ─── letter→event link (#850, REQ-009) ─────────────────────────────────── + + @Test + void letter_in_a_curated_events_documents_carries_that_events_id() { + // REQ-009: 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-009: a letter referenced by no curated event → linkedEventId null; the frontend + // then renders it as a loose chronological letter (REQ-006). + 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(); + } + + @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()); + } + + @Test + void letter_linked_only_to_a_filtered_out_event_has_null_linkedEventId() { + // finding #10: the link pass runs over the events that survived the filter, not all of + // them. A letter whose only linking event is excluded by the active filter links to + // nothing (the frontend would drop it loose anyway) — and the lazy `documents` collection + // is never hydrated for events that are off-screen. + Document letterDoc = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH); + TimelineEvent worldEvent = TimelineEvent.builder().id(UUID.randomUUID()) + .title("Somme").type(EventType.HISTORICAL) + .documents(new HashSet<>(Set.of(letterDoc))) + .build(); + when(eventRepository.findAll()).thenReturn(List.of(worldEvent)); + when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of()); + when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc)); + + // Filter to PERSONAL only → the HISTORICAL event is filtered out of the view. + TimelineEntryDTO entry = theLetter(timelineService.assemble( + new TimelineFilter(null, null, EventType.PERSONAL, null, null))); + + assertThat(entry.linkedEventId()).isNull(); + } + + 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); @@ -523,7 +623,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) { diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 6be0122c..2d819091 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1049,10 +1049,12 @@ "timeline_derived_birth": "Geburt", "timeline_derived_death": "Tod", "timeline_derived_marriage": "Heirat", - "timeline_grouping_date": "Gruppierung: Datum", "timeline_provenance_derived": "abgeleitet", "timeline_provenance_curated": "kuratiert", + "timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen", + "timeline_bucket_show_less": "Weniger anzeigen", "timeline_letter_glyph_label": "Brief", + "timeline_cluster_letter_count": "{count} Briefe", "timeline_tag_chip_label": "Thema", "timeline_layer_historical_suffix": "historisch", "timeline_strip_density_caption": "Monats-Dichte", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b07fe58e..a9ed5ce8 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1049,10 +1049,12 @@ "timeline_derived_birth": "Birth", "timeline_derived_death": "Death", "timeline_derived_marriage": "Marriage", - "timeline_grouping_date": "Grouping: Date", "timeline_provenance_derived": "derived", "timeline_provenance_curated": "curated", + "timeline_bucket_show_more": "+ {count} more letters", + "timeline_bucket_show_less": "Show fewer", "timeline_letter_glyph_label": "Letter", + "timeline_cluster_letter_count": "{count} letters", "timeline_tag_chip_label": "Topic", "timeline_layer_historical_suffix": "historical", "timeline_strip_density_caption": "Monthly density", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2048c1f6..15239a92 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1049,10 +1049,12 @@ "timeline_derived_birth": "Nacimiento", "timeline_derived_death": "Fallecimiento", "timeline_derived_marriage": "Matrimonio", - "timeline_grouping_date": "Agrupación: Fecha", "timeline_provenance_derived": "derivado", "timeline_provenance_curated": "curado", + "timeline_bucket_show_more": "+ {count} cartas más", + "timeline_bucket_show_less": "Mostrar menos", "timeline_letter_glyph_label": "Carta", + "timeline_cluster_letter_count": "{count} cartas", "timeline_tag_chip_label": "Tema", "timeline_layer_historical_suffix": "histórico", "timeline_strip_density_caption": "Densidad mensual", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 33f9b3ab..e994e031 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2467,6 +2467,8 @@ export interface components { rootTagId?: string; rootTagName?: string; rootTagColor?: string; + /** Format: uuid */ + linkedEventId?: string; }; TimelineYearDTO: { /** Format: int32 */ diff --git a/frontend/src/lib/messages.spec.ts b/frontend/src/lib/messages.spec.ts index 155ae1a3..1003bf37 100644 --- a/frontend/src/lib/messages.spec.ts +++ b/frontend/src/lib/messages.spec.ts @@ -74,9 +74,10 @@ describe('message key parity', () => { // every locale so no surface ever falls back to a missing translation. it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => { const requiredKeys = [ - 'timeline_grouping_date', 'timeline_provenance_derived', 'timeline_provenance_curated', + 'timeline_bucket_show_more', + 'timeline_bucket_show_less', 'timeline_letter_glyph_label', 'timeline_layer_historical_suffix', 'timeline_strip_density_caption', @@ -99,6 +100,14 @@ describe('message key parity', () => { expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' }); }); + // #850 finding #6: the event-card letter count carries an sr-only "{count} Briefe" so the + // bare "· 2" never announces to a screen reader without context. + it('zeitstrahl cluster letter-count key is present in all locales (#850 finding #6)', () => { + expect(de).toHaveProperty('timeline_cluster_letter_count'); + expect(en).toHaveProperty('timeline_cluster_letter_count'); + expect(es).toHaveProperty('timeline_cluster_letter_count'); + }); + // #780 REQ-010: the layer-filter strings are Paraglide keys in every locale. // timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count}, // ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)". diff --git a/frontend/src/lib/timeline/EventCluster.svelte b/frontend/src/lib/timeline/EventCluster.svelte new file mode 100644 index 00000000..29f13497 --- /dev/null +++ b/frontend/src/lib/timeline/EventCluster.svelte @@ -0,0 +1,104 @@ + + +

+ {#if event} + +
+ +
+ {:else} + +
+ + + {title} + + + + {m.timeline_cluster_letter_count({ count })} + +
+ {/if} + +
+ + {#if hiddenCount > 0} + + {/if} +
+
diff --git a/frontend/src/lib/timeline/EventCluster.svelte.spec.ts b/frontend/src/lib/timeline/EventCluster.svelte.spec.ts new file mode 100644 index 00000000..ed8f109b --- /dev/null +++ b/frontend/src/lib/timeline/EventCluster.svelte.spec.ts @@ -0,0 +1,129 @@ +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 EventCluster from './EventCluster.svelte'; +import { makeEntry } from './test-factories'; +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +afterEach(() => cleanup()); + +const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +const makeEvent = (overrides: Partial = {}): TimelineEntryDTO => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + documentId: undefined, + eventId: EV_ID, + eventDate: '1916-07-06', + precision: 'DAY', + title: 'Ein gewaltiger Stadtbrand', + ...overrides + }); + +const letters = (n: number): TimelineEntryDTO[] => + Array.from({ length: n }, (_, i) => + makeEntry({ kind: 'LETTER', documentId: `doc-${i}`, title: `Brief ${i}`, linkedEventId: EV_ID }) + ); + +describe('EventCluster — contained event card (#850)', () => { + it('renders a data-testid event-card with the event title once (REQ-002)', () => { + render(EventCluster, { letters: letters(2), event: makeEvent() }); + expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull(); + const occurrences = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []) + .length; + expect(occurrences).toBe(1); + }); + + it('shows the event-edit link for a curator on a curated event (REQ-002)', () => { + render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: true }); + const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement; + expect(edit).not.toBeNull(); + expect(edit.getAttribute('href')).toBe(`/zeitstrahl/events/${EV_ID}/edit`); + }); + + it('hides the event-edit link when canWrite is false', () => { + render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: false }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('hides the event-edit link for a derived event even with canWrite', () => { + render(EventCluster, { + letters: letters(2), + event: makeEvent({ derived: true, eventId: undefined, derivedType: 'BIRTH' }), + canWrite: true + }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('renders its letters as compact a.lcard.ev cards (REQ-002)', () => { + render(EventCluster, { letters: letters(2), event: makeEvent() }); + expect(document.querySelectorAll('a.lcard.ev').length).toBeGreaterThan(0); + }); + + it('shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5 (REQ-003)', async () => { + render(EventCluster, { letters: letters(8), event: makeEvent() }); + expect(document.querySelectorAll('a.lcard').length).toBe(5); + const toggle = document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement; + expect(toggle).not.toBeNull(); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + + toggle.click(); + await tick(); + expect(document.querySelectorAll('a.lcard').length).toBe(8); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + + toggle.click(); + await tick(); + expect(document.querySelectorAll('a.lcard').length).toBe(5); + }); + + it('renders no show-more toggle when the cluster holds 5 or fewer letters', () => { + render(EventCluster, { letters: letters(5), event: makeEvent() }); + expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull(); + }); + + it('renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given (REQ-004)', () => { + render(EventCluster, { + letters: letters(2), + title: 'Briefe von der Front', + canWrite: true + }); + expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull(); + expect(document.body.textContent).toContain('✉'); + expect(document.body.textContent).toContain('Briefe von der Front'); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('pairs the cross-year ✉ glyph with an sr-only label so it is not a silent glyph (finding #6)', () => { + render(EventCluster, { letters: letters(2), title: 'Briefe von der Front' }); + const header = document.querySelector('[data-testid="event-header"]') as HTMLElement; + const hidden = header.querySelector('[aria-hidden="true"]'); + expect(hidden?.textContent).toContain('✉'); + const srOnly = header.querySelector('.sr-only'); + expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label()); + }); + + it('gives the letter count an sr-only "{count} Briefe" label so "· 2" is not announced bare (finding #6)', () => { + render(EventCluster, { letters: letters(2), title: 'Briefe von der Front' }); + const count = document.querySelector('[data-testid="event-count"]') as HTMLElement; + // the visible "· 2" stays aria-hidden; the sr-only sibling carries the meaning + expect(count.querySelector('[aria-hidden="true"]')?.textContent).toContain('· 2'); + expect(count.querySelector('.sr-only')?.textContent).toBe( + m.timeline_cluster_letter_count({ count: 2 }) + ); + }); + + it('renders an HTML-bearing event title verbatim as text, never as markup (REQ-010)', () => { + render(EventCluster, { + letters: letters(1), + event: makeEvent({ title: '' }) + }); + expect(document.querySelector('[data-testid="event-card"] img')).toBeNull(); + expect(document.body.textContent).toContain(''); + }); +}); diff --git a/frontend/src/lib/timeline/EventHeader.svelte b/frontend/src/lib/timeline/EventHeader.svelte new file mode 100644 index 00000000..6d639083 --- /dev/null +++ b/frontend/src/lib/timeline/EventHeader.svelte @@ -0,0 +1,69 @@ + + + + + + + {#if entry.title} + {entry.title} + {/if} + + {subtitle} + {#if count !== undefined} + + + {m.timeline_cluster_letter_count({ count })} + + {/if} + + +{#if canEdit} + + + {m.btn_edit()} + +{/if} diff --git a/frontend/src/lib/timeline/EventHeader.svelte.spec.ts b/frontend/src/lib/timeline/EventHeader.svelte.spec.ts new file mode 100644 index 00000000..960ca859 --- /dev/null +++ b/frontend/src/lib/timeline/EventHeader.svelte.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import * as m from '$lib/paraglide/messages.js'; +import EventHeader from './EventHeader.svelte'; +import { makeEntry } from './test-factories'; +import type { components } from '$lib/generated/api'; + +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +afterEach(() => cleanup()); + +const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +const curated = (overrides: Partial = {}): TimelineEntryDTO => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: EV_ID, + eventDate: '1916-07-06', + precision: 'DAY', + title: 'Ein gewaltiger Stadtbrand', + documentId: undefined, + ...overrides + }); + +describe('EventHeader', () => { + it('renders the glyph with an sr-only label, the title, and the provenance subtitle', () => { + render(EventHeader, { entry: curated() }); + expect(document.querySelector('.sr-only')?.textContent).toBe(m.timeline_layer_family()); + expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand'); + expect(document.body.textContent).toContain(m.timeline_provenance_curated()); + }); + + it('shows the edit pencil for a writer on a curated event (canEditEvent gate)', () => { + render(EventHeader, { entry: curated(), canWrite: true }); + const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement; + expect(edit).not.toBeNull(); + expect(edit.getAttribute('href')).toBe(`/zeitstrahl/events/${EV_ID}/edit`); + }); + + it('hides the edit pencil without write, for a derived event, and for a null eventId', () => { + render(EventHeader, { entry: curated(), canWrite: false }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + cleanup(); + render(EventHeader, { entry: curated({ derived: true }), canWrite: true }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + cleanup(); + render(EventHeader, { entry: curated({ eventId: undefined }), canWrite: true }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('renders a screen-reader-labeled letter count when a count is given', () => { + render(EventHeader, { entry: curated(), count: 3 }); + const count = document.querySelector('[data-testid="event-count"]') as HTMLElement; + expect(count.querySelector('[aria-hidden="true"]')?.textContent).toContain('· 3'); + expect(count.querySelector('.sr-only')?.textContent).toBe( + m.timeline_cluster_letter_count({ count: 3 }) + ); + }); + + it('omits the letter count when no count is given (the pill case)', () => { + render(EventHeader, { entry: curated() }); + expect(document.querySelector('[data-testid="event-count"]')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte index b8573119..b71e7856 100644 --- a/frontend/src/lib/timeline/EventPill.svelte +++ b/frontend/src/lib/timeline/EventPill.svelte @@ -1,32 +1,21 @@
@@ -36,32 +25,6 @@ const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null); ? 'border-2 border-brand-mint' : 'border border-brand-navy'}" > - - - {config.label} - - - {#if entry.title} - {entry.title} - {/if} - {subtitle} - - {#if canEdit} - - - {m.btn_edit()} - - {/if} +
diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte index 593156fc..1e3b334f 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -11,11 +11,33 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; * A single archive letter on the timeline: sender → receiver, title, and a * precision-aware date chip, linking to the document. Names/titles are * OCR/import-derived — rendered via default `{...}` escaping with - * `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive. + * `whitespace-pre-line` for line breaks (REQ-010); never the raw-HTML directive. + * + * Inside an event cluster the card sits in the contained event card and renders as + * the `.lcard.ev` `compact` variant (#850, REQ-002): tighter row, and the redundant + * date chip is dropped when the title already embeds the date. The per-letter tag + * chip can be suppressed via `suppressTagChip` for callers that already convey it. */ -let { entry }: { entry: TimelineEntryDTO } = $props(); +let { + entry, + variant = 'plain', + suppressTagChip = false, + compact = false +}: { + entry: TimelineEntryDTO; + variant?: 'plain' | 'event'; + suppressTagChip?: boolean; + compact?: boolean; +} = $props(); +const isEventVariant = $derived(variant === 'event'); const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)); +// Inside an event card the band frames the time, so a compact in-card letter drops the +// redundant date chip — but ONLY when the (free-form OCR) title actually embeds the formatted +// date, e.g. "H-0023 – 6. Juli 1916". A title without the date keeps its chip, so a letter like +// "Brief an Mutter" never loses its month/day (the band frames only the year) — #850, finding #4. +const titleEmbedsDate = $derived(!!dateLabel && !!entry.title && entry.title.includes(dateLabel)); +const showDate = $derived(!compact || !titleEmbedsDate); const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName); const receiver = $derived( entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName @@ -28,28 +50,37 @@ const receiver = $derived( {#if entry.title} - + {entry.title} {/if} - + {sender} {receiver} - {#if dateLabel} + {#if dateLabel && showDate} · {dateLabel} {/if} - {#if entry.rootTagName} + {#if entry.rootTagName && !suppressTagChip} + (#835 §3); absent when the letter has no tag (REQ-006), and suppressed when + the caller already conveys the topic (suppressTagChip). --> {/if} diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts index b60c0f5f..349a2e72 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts @@ -127,3 +127,58 @@ describe('LetterCard', () => { expect(chip?.textContent).toContain('Familie'); }); }); + +describe('LetterCard — event-cluster variants (#850, REQ-002)', () => { + it('carries the .lcard.ev class in the event variant (REQ-002)', () => { + render(LetterCard, { entry: makeEntry(), variant: 'event' }); + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + }); + + it('is a plain card with no .ev marker by default (REQ-006)', () => { + render(LetterCard, { entry: makeEntry() }); + expect(document.querySelector('a.ev')).toBeNull(); + }); + + it('suppresses the per-letter tag chip when asked, even with a root tag', () => { + render(LetterCard, { + entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }), + suppressTagChip: true + }); + expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull(); + }); + + it('still shows the per-letter tag chip when not suppressed', () => { + render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) }); + expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull(); + }); + + it('drops the compact date chip only when the title actually embeds the formatted date (#850)', () => { + // An archive title like "H-0023 – 6. Juli 1916" already carries the date, so inside an + // event card (where the band frames the time) the redundant chip is dropped. + const entry = makeEntry({ eventDate: '1916-07-06', precision: 'DAY' }); + const dateLabel = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd); + render(LetterCard, { entry: { ...entry, title: `H-0023 – ${dateLabel}` }, compact: true }); + expect(document.querySelector('[data-testid="letter-date"]')).toBeNull(); + expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown + }); + + it('keeps the compact date chip when the title does NOT embed the date (#850, finding #4)', () => { + // Titles are free-form OCR text — a titled letter whose title carries no date must keep + // its month/day, since inside an event card the band frames only the year. + render(LetterCard, { + entry: makeEntry({ eventDate: '1916-07-06', precision: 'DAY', title: 'Brief an Mutter' }), + compact: true + }); + expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull(); + }); + + it('keeps the date in the compact variant when the letter has no title (#850)', () => { + render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true }); + expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull(); + }); + + it('renders the compact variant on a single tighter row (#850)', () => { + render(LetterCard, { entry: makeEntry(), compact: true }); + expect(document.querySelector('a.lcard.compact')).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte index c3008e90..bee97daf 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -6,6 +6,7 @@ import LetterCard from './LetterCard.svelte'; import EventPill from './EventPill.svelte'; import WorldBand from './WorldBand.svelte'; import { entryKey } from './entryKey'; +import { buildEventLookup } from './eventClustering'; import type { components } from '$lib/generated/api'; type TimelineDTO = components['schemas']['TimelineDTO']; @@ -18,6 +19,11 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO']; * empty timeline shows a calm message (REQ-017). `personId` is a declared seam * for the per-person rail (issue #10) and is undefined here; it is not passed to * leaf cards (REQ-025). Owns no
— the layout does. + * + * The event lookup is built once over the whole (already layer-filtered) timeline + * and threaded to every band so a curated event's letters cluster under it inline + * (#850, REQ-002). The undated bucket stays plain (events as pills, letters as + * cards) — out of clustering scope. */ let { timeline, @@ -25,6 +31,8 @@ let { canWrite = false }: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props(); +const eventLookup = $derived(buildEventLookup(timeline)); + type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number }; const rows = $derived.by(() => { @@ -54,7 +62,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
  • {#if row.t === 'band'} - + {:else} {/if} diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts index 6e5b42c0..00e72e47 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -341,4 +341,67 @@ describe('TimelineView', () => { expect(hrefs).toContain('/zeitstrahl/events/wb/edit'); expect(hrefs).toContain('/zeitstrahl/events/wu/edit'); }); + + it('builds the event lookup and clusters a curated event + same-year linked letter into an event-card (#850)', () => { + const evId = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'; + const event = makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId: evId, + eventDate: '1916-07-06', + precision: 'DAY', + title: 'Ein gewaltiger Stadtbrand', + senderName: '', + receiverName: '', + documentId: undefined + }); + const letter = makeEntry({ + eventDate: '1916-05-10', + documentId: 'doc-linked', + title: 'Brief', + linkedEventId: evId + }); + render(TimelineView, { + timeline: makeTimelineDTO({ years: [makeYear(1916, [event, letter])] }) + }); + expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull(); + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + // the title reads once — the event is the card header, not also a loose pill + const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length; + expect(titles).toBe(1); + }); + + it('keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)', () => { + const evId = 'dddddddd-dddd-dddd-dddd-dddddddddddd'; + const world = makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + derived: false, + eventId: evId, + eventDate: '1916-07-01', + precision: 'DAY', + title: 'Schlacht an der Somme', + senderName: '', + receiverName: '', + documentId: undefined + }); + const letter = makeEntry({ + eventDate: '1916-05-10', + documentId: 'doc-world-linked', + title: 'Brief von der Front', + linkedEventId: evId + }); + render(TimelineView, { + timeline: makeTimelineDTO({ years: [makeYear(1916, [world, letter])] }) + }); + // the world event stays a full-width band — no contained event card + expect(document.querySelector('[data-testid="event-card"]')).toBeNull(); + expect(document.querySelector('a.lcard.ev')).toBeNull(); + // the linked letter renders loose on the spine, not inside a card + expect(document.querySelector('.letter-row')).not.toBeNull(); + // and the band keeps its WorldBand "· historisch" register + expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix()); + expect(document.body.textContent).toContain('Schlacht an der Somme'); + }); }); diff --git a/frontend/src/lib/timeline/WorldBand.svelte b/frontend/src/lib/timeline/WorldBand.svelte index 9dbab74f..ff3da75f 100644 --- a/frontend/src/lib/timeline/WorldBand.svelte +++ b/frontend/src/lib/timeline/WorldBand.svelte @@ -1,6 +1,6 @@
    diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index fafa0b4c..f1e62d0e 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -3,8 +3,10 @@ import EventPill from './EventPill.svelte'; import WorldBand from './WorldBand.svelte'; import LetterCard from './LetterCard.svelte'; import YearLetterStrip from './YearLetterStrip.svelte'; +import EventCluster from './EventCluster.svelte'; import { isDense } from './timelineDensity'; import { entryKey } from './entryKey'; +import { splitYearLetters, type EventCluster as EventClusterModel } from './eventClustering'; import type { components } from '$lib/generated/api'; type TimelineYearDTO = components['schemas']['TimelineYearDTO']; @@ -12,37 +14,113 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; /** * One year of the timeline: a
    with a sticky

    (REQ-006). Events - * render in DTO order as pills/bands; letters render as individual cards while - * the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that - * (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003). + * render in DTO order as pills/world-bands; entries are never re-sorted (REQ-003). + * + * A curated event with letters linked to it (#850) becomes a contained event card: + * the event IS the card header and its linked letters sit inside (no separate pill — + * REQ-002). A curated event with letters in another year band renders here as a + * cross-year text-header card (REQ-004). An event with no linked letters stays a + * plain pill/world-band (REQ-005). + * + * Every other letter (no linkedEventId, or linking to an event the #780 layer filter + * removed) stays loose: alternating left/right while the band holds ≤ 12 such loose + * letters (REQ-006), folding into a single month-density strip above that (REQ-007). + * The loose-letter layout and the strip count ONLY these loose letters — clustered + * letters never re-appear loose (REQ-007). */ -let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props(); +let { + year, + canWrite = false, + eventLookup +}: { + year: TimelineYearDTO; + canWrite?: boolean; + eventLookup?: Map; +} = $props(); type Row = | { t: 'event'; entry: TimelineEntryDTO } + | { t: 'eventcard'; event?: TimelineEntryDTO; cluster: EventClusterModel } | { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' } | { t: 'strip' }; -const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER')); -const dense = $derived(isDense(letters.length)); +// Split this band's letters into event clusters and the loose remainder once; the loose +// list alone drives the alternating layout and the density strip (REQ-007). +const split = $derived( + splitYearLetters( + year.entries.filter((e) => e.kind === 'LETTER'), + eventLookup + ) +); +const loose = $derived(split.loose); +const dense = $derived(isDense(loose.length)); +// Clusters keyed by eventId (built once in splitYearLetters): row assembly looks a letter's +// disposition up in O(1) — `byEvent.has(linkedEventId)` — instead of scanning the loose array +// per letter (was O(L²) on a dense band), and resolves an event's card with `byEvent.get`. +const byEvent = $derived(split.byEvent); + +// Event ids that have a same-year EVENT entry in THIS band: those clusters render as that +// event's header (at the EVENT position); every other cluster is cross-year (REQ-004/015). +const sameYearEventIds = $derived.by>(() => { + const ids: Record = {}; + for (const entry of year.entries) { + if (entry.kind === 'EVENT' && entry.eventId) ids[entry.eventId] = true; + } + return ids; +}); const rows = $derived.by(() => { const out: Row[] = []; + const emitted: Record = {}; let stripInserted = false; let letterIndex = 0; + for (const entry of year.entries) { if (entry.kind === 'EVENT') { - out.push({ t: 'event', entry }); - } else if (!dense) { - out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' }); - letterIndex += 1; - } else if (!stripInserted) { - out.push({ t: 'strip' }); - stripInserted = true; + // A curated event whose letters live in THIS band becomes the contained card's + // header — its title reads once, no separate pill (REQ-002). Otherwise it stays a + // plain pill/world-band (REQ-005). + const cluster = entry.eventId ? byEvent.get(entry.eventId) : undefined; + if (cluster) { + out.push({ t: 'eventcard', event: entry, cluster }); + emitted[cluster.eventId] = true; + } else { + out.push({ t: 'event', entry }); + } + continue; + } + + const cluster = entry.linkedEventId ? byEvent.get(entry.linkedEventId) : undefined; + if (!cluster) { + // A loose letter (not clustered): alternate while sparse, or fold the whole loose set + // into one density strip (inserted once, at the first loose letter) when dense. + if (!dense) { + out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' }); + letterIndex += 1; + } else if (!stripInserted) { + out.push({ t: 'strip' }); + stripInserted = true; + } + continue; + } + + // A clustered letter. A same-year cluster is emitted at its EVENT entry, so skip it here. + // A cross-year cluster has no EVENT anchor in this band — emit its ✉ card HERE, at the + // position of its earliest linked letter, so the band stays in strict time order (REQ-015). + if (!sameYearEventIds[cluster.eventId] && !emitted[cluster.eventId]) { + out.push({ t: 'eventcard', cluster }); + emitted[cluster.eventId] = true; } } + return out; }); + +function rowKey(row: Row): string { + if (row.t === 'strip') return `strip-${year.year}`; + if (row.t === 'eventcard') return `evcard:${row.cluster.eventId}`; + return entryKey(row.entry); +}
    @@ -56,20 +134,27 @@ const rows = $derived.by(() => {

    - {#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))} + {#each rows as row (rowKey(row))} {#if row.t === 'event'} {#if row.entry.type === 'HISTORICAL'} {:else} {/if} + {:else if row.t === 'eventcard'} + {:else if row.t === 'letter'}
    {:else} - + {/if} {/each}
    diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts index 45845080..9bada13a 100644 --- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -165,3 +165,126 @@ describe('YearBand', () => { } }); }); + +describe('YearBand — inline event clustering (#850)', () => { + const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + + function curatedEvent(overrides = {}) { + return makeEntry({ + kind: 'EVENT', + derived: false, + type: 'PERSONAL', + eventId: EV_ID, + eventDate: '1916-07-06', + precision: 'DAY', + title: 'Ein gewaltiger Stadtbrand', + senderName: '', + receiverName: '', + documentId: undefined, + ...overrides + }); + } + + function linkedLetters(year: number, count: number, eventId = EV_ID) { + return Array.from({ length: count }, (_, i) => + makeEntry({ + eventDate: `${year}-05-10`, + documentId: `linked-${i}`, + title: `Brief ${i}`, + linkedEventId: eventId + }) + ); + } + + const lookup = new Map([[EV_ID, 'Ein gewaltiger Stadtbrand']]); + + it('renders a curated event with a same-year linked letter as one event-card, title once, no separate pill (REQ-002)', () => { + render(YearBand, { + year: makeYear(1916, [curatedEvent(), ...linkedLetters(1916, 1)]), + eventLookup: lookup + }); + expect(document.querySelectorAll('[data-testid="event-card"]')).toHaveLength(1); + const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length; + expect(titles).toBe(1); + // the letter is inside the card, not a loose .letter-row + expect(document.querySelector('a.lcard.ev')).not.toBeNull(); + expect(document.querySelector('.letter-row')).toBeNull(); + // no plain EventPill for it (the pill is the only floating .rounded-full wrapper) + expect(document.querySelector('.justify-center .rounded-full.border-brand-mint')).toBeNull(); + }); + + it('renders a curated event with NO linked letters as a plain EventPill, no card (REQ-005)', () => { + render(YearBand, { + year: makeYear(1916, [curatedEvent()]), + eventLookup: lookup + }); + expect(document.querySelector('[data-testid="event-card"]')).toBeNull(); + // the curated EventPill is the bordered floating rounded-full wrapper + expect( + document.querySelector('.justify-center .rounded-full.border-brand-mint') + ).not.toBeNull(); + expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand'); + }); + + it('renders loose (unlinked) letters in a sparse year as alternating .letter-row, no card (REQ-006)', () => { + const loose = manyLetters(1916, 3); // no linkedEventId + render(YearBand, { year: makeYear(1916, loose), eventLookup: lookup }); + expect(document.querySelectorAll('.letter-row')).toHaveLength(3); + expect(document.querySelector('[data-testid="event-card"]')).toBeNull(); + }); + + it('counts only loose letters in the density strip; event letters stay in the card (REQ-006/007)', () => { + // 15 loose letters fold into one strip; a 3-letter event card shows its 3. + const loose = manyLetters(1916, 15); + const year = makeYear(1916, [curatedEvent(), ...linkedLetters(1916, 3), ...loose]); + render(YearBand, { year, eventLookup: lookup }); + // the event card holds 3 letters + expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(3); + // the loose letters fold into exactly one density strip + const strips = document.querySelectorAll('[data-testid="strip-expand"]'); + expect(strips).toHaveLength(1); + // the strip card's count text is 15 (the loose letters), not 18 (REQ-006/007) + const stripCard = strips[0].closest('.max-w-md') as HTMLElement; + expect(stripCard.textContent).toContain('15'); + expect(stripCard.textContent).not.toContain('18'); + }); + + it('renders a cross-year cluster (event not in this band) as a ✉ text-header card holding it (REQ-004)', () => { + // The event id is in eventLookup but no matching EVENT entry sits in this band. + render(YearBand, { + year: makeYear(1917, linkedLetters(1917, 2)), + eventLookup: lookup + }); + const card = document.querySelector('[data-testid="event-card"]'); + expect(card).not.toBeNull(); + expect(document.body.textContent).toContain('✉'); + expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand'); + // cross-year card carries no edit link and no pill + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + expect(document.querySelector('.justify-center .rounded-full.border-brand-mint')).toBeNull(); + }); + + it('interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)', () => { + // Chronological band order (what the backend delivers): a February cross-year letter, then + // a November loose letter. The cross-year card must sit at its earliest letter's position — + // before the November loose letter — so the band still reads in strict time order. + const febLinked = makeEntry({ + eventDate: '1917-02-10', + documentId: 'feb-linked', + title: 'Feldpostbrief', + linkedEventId: EV_ID + }); + const novLoose = makeEntry({ + eventDate: '1917-11-20', + documentId: 'nov-loose', + title: 'Brief im November' + }); + render(YearBand, { year: makeYear(1917, [febLinked, novLoose]), eventLookup: lookup }); + const card = document.querySelector('[data-testid="event-card"]') as HTMLElement; + const looseLink = document.querySelector('a[href="/documents/nov-loose"]') as HTMLElement; + expect(card).not.toBeNull(); + expect(looseLink).not.toBeNull(); + // the cross-year card precedes the later-dated loose letter in DOM order + expect(card.compareDocumentPosition(looseLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/timeline/eventCardConfig.spec.ts b/frontend/src/lib/timeline/eventCardConfig.spec.ts index 8fd33355..ac1b49cb 100644 --- a/frontend/src/lib/timeline/eventCardConfig.spec.ts +++ b/frontend/src/lib/timeline/eventCardConfig.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getAccentConfig } from './eventCardConfig'; +import { getAccentConfig, canEditEvent } from './eventCardConfig'; import type { components } from '$lib/generated/api'; type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; @@ -51,3 +51,24 @@ describe('getAccentConfig', () => { expect(cfg.accent).toBe('curated'); }); }); + +// The single source of the curator edit-affordance gate (CLAUDE.md's TimelineEntryDTO contract): +// a curated event shows its edit pencil only for a writer, never for a derived life-event or a +// null eventId. Shared by EventPill, WorldBand, and EventCluster (#850 finding #5). +describe('canEditEvent', () => { + it('allows a writer to edit a curated event with an eventId', () => { + expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), true)).toBe(true); + }); + + it('denies a viewer without write permission', () => { + expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), false)).toBe(false); + }); + + it('denies a derived life-event even for a writer', () => { + expect(canEditEvent(event({ derived: true, eventId: 'e-1' }), true)).toBe(false); + }); + + it('denies an event with no eventId even for a writer', () => { + expect(canEditEvent(event({ derived: false, eventId: undefined }), true)).toBe(false); + }); +}); diff --git a/frontend/src/lib/timeline/eventCardConfig.ts b/frontend/src/lib/timeline/eventCardConfig.ts index cb11d7b2..7d0aeb1f 100644 --- a/frontend/src/lib/timeline/eventCardConfig.ts +++ b/frontend/src/lib/timeline/eventCardConfig.ts @@ -36,3 +36,16 @@ export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig { } return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' }; } + +/** + * The curator edit-affordance gate, in one place — the security-relevant contract documented on + * CLAUDE.md's `TimelineEntryDTO` row (`derived || eventId == null` → no edit link). A curated + * event's edit pencil shows only for a viewer with WRITE_ALL (`canWrite`), and only when it is a + * real curated event: never a derived life-event (nothing to edit) and never a null `eventId`. + * HISTORICAL events are never derived, so this also covers the world band. The gate is UX only — + * the #781 route guard + backend permission are the real boundary. Shared by EventPill, WorldBand, + * and EventCluster so the gate has a single source of truth (#850 finding #5). + */ +export function canEditEvent(entry: TimelineEntryDTO, canWrite: boolean): boolean { + return canWrite && !entry.derived && entry.eventId != null; +} diff --git a/frontend/src/lib/timeline/eventClustering.spec.ts b/frontend/src/lib/timeline/eventClustering.spec.ts new file mode 100644 index 00000000..4b42fea9 --- /dev/null +++ b/frontend/src/lib/timeline/eventClustering.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { buildEventLookup, splitYearLetters, CLUSTER_PREVIEW } from './eventClustering'; +import { makeEntry } from './test-factories'; +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +const EV_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const EV_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +const makeEvent = (overrides: Partial = {}): TimelineEntryDTO => + makeEntry({ kind: 'EVENT', type: 'PERSONAL', documentId: undefined, ...overrides }); + +describe('eventClustering — buildEventLookup', () => { + it('maps a curated year-band event to its title, excluding undated-bucket events (#7)', () => { + const timeline: TimelineDTO = { + years: [ + { + year: 1916, + entries: [makeEvent({ eventId: EV_A, title: 'Ein gewaltiger Stadtbrand' })] + } + ], + undated: [makeEvent({ eventId: EV_B, title: 'Briefe von der Front' })] + }; + const lookup = buildEventLookup(timeline); + expect(lookup.get(EV_A)).toBe('Ein gewaltiger Stadtbrand'); + // An undated event renders as a plain pill in the undated bucket — out of clustering + // scope. Including it here would scatter its dated letters into orphaned ✉ cross-year + // cards detached from the pill (#7), so it must NOT enter the lookup. + expect(lookup.has(EV_B)).toBe(false); + expect(lookup.size).toBe(1); + }); + + it('ignores derived events (no eventId) and letters', () => { + const timeline: TimelineDTO = { + years: [ + { + year: 1916, + entries: [ + makeEvent({ eventId: undefined, title: 'Geburt' }), // derived + makeEntry({ kind: 'LETTER', documentId: 'doc-1' }) + ] + } + ], + undated: [] + }; + expect(buildEventLookup(timeline).size).toBe(0); + }); + + it('excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)', () => { + const timeline: TimelineDTO = { + years: [ + { year: 1916, entries: [makeEvent({ eventId: EV_A, type: 'HISTORICAL', title: 'Somme' })] } + ], + undated: [] + }; + const lookup = buildEventLookup(timeline); + expect(lookup.has(EV_A)).toBe(false); + expect(lookup.size).toBe(0); + }); + + it('skips an event with an empty or whitespace title — no bare ✉ card (#8)', () => { + const timeline: TimelineDTO = { + years: [ + { + year: 1916, + entries: [ + makeEvent({ eventId: EV_A, title: '' }), + makeEvent({ eventId: EV_B, title: ' ' }) + ] + } + ], + undated: [] + }; + expect(buildEventLookup(timeline).size).toBe(0); + }); +}); + +describe('eventClustering — splitYearLetters', () => { + it('exposes a CLUSTER_PREVIEW of 5', () => { + expect(CLUSTER_PREVIEW).toBe(5); + }); + + it('clusters letters by linkedEventId with matching counts', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [ + makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }), + makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: EV_A }) + ]; + const { clusters, loose } = splitYearLetters(letters, lookup); + expect(clusters).toHaveLength(1); + expect(clusters[0].eventId).toBe(EV_A); + expect(clusters[0].title).toBe('Stadtbrand'); + expect(clusters[0].letters).toHaveLength(2); + expect(loose).toHaveLength(0); + }); + + it('keeps a letter with no linkedEventId loose', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: undefined })]; + const { clusters, loose } = splitYearLetters(letters, lookup); + expect(clusters).toHaveLength(0); + expect(loose).toHaveLength(1); + }); + + it('keeps a letter whose linkedEventId is absent from the lookup loose (filter-then-cluster, REQ-008)', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_B })]; + const { clusters, loose } = splitYearLetters(letters, lookup); + expect(clusters).toHaveLength(0); + expect(loose).toHaveLength(1); + }); + + it('places each letter in exactly one place (REQ-007)', () => { + const lookup = new Map([[EV_A, 'Stadtbrand']]); + const letters = [ + makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }), + makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: undefined }), + makeEntry({ kind: 'LETTER', documentId: 'd3', linkedEventId: EV_B }) + ]; + const { clusters, loose } = splitYearLetters(letters, lookup); + const clustered = clusters.flatMap((c) => c.letters.length).reduce((a, b) => a + b, 0); + expect(clustered + loose.length).toBe(3); + expect(clustered).toBe(1); + expect(loose).toHaveLength(2); + }); + + it('keeps clusters in first-seen order', () => { + const lookup = new Map([ + [EV_B, 'Front'], + [EV_A, 'Stadtbrand'] + ]); + const letters = [ + makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }), + makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: EV_B }) + ]; + const { clusters } = splitYearLetters(letters, lookup); + expect(clusters.map((c) => c.eventId)).toEqual([EV_A, EV_B]); + }); +}); diff --git a/frontend/src/lib/timeline/eventClustering.ts b/frontend/src/lib/timeline/eventClustering.ts new file mode 100644 index 00000000..212c7338 --- /dev/null +++ b/frontend/src/lib/timeline/eventClustering.ts @@ -0,0 +1,88 @@ +import type { components } from '$lib/generated/api'; + +type TimelineDTO = components['schemas']['TimelineDTO']; +type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; + +/** Letters shown inside an event card before a "show more" toggle appears (#850, REQ-003). */ +export const CLUSTER_PREVIEW = 5; + +/** One contained event card's worth of letters within a year band (#850). */ +export interface EventCluster { + /** The curated event's id — also the `{#each}` key. */ + eventId: string; + /** The curated event's title (from the event lookup). */ + title: string; + letters: TimelineEntryDTO[]; +} + +/** The result of splitting a year's letters into event clusters and the loose remainder. */ +export interface SplitLetters { + clusters: EventCluster[]; + loose: TimelineEntryDTO[]; + /** Clusters keyed by `eventId` for O(1) lookup during row assembly (a letter's disposition is + * `byEvent.has(linkedEventId)`; an event's card is `byEvent.get(eventId)`). */ + byEvent: Map; +} + +/** + * Maps each curated event present in the (already layer-filtered) timeline to its title. These + * are the only events a letter may cluster under — a letter whose `linkedEventId` is absent here + * links to an event the #780 layer filter removed, so it falls back to a loose chronological + * letter (filter-then-cluster, REQ-008). Curated PERSONAL events carry an `eventId`; derived + * life-events and letters do not, so they never enter the lookup. HISTORICAL events are excluded + * too: a world event always keeps its full-width WorldBand and never clusters, even with linked + * letters (REQ-014) — those letters stay loose. + * + * Only year-band events are collected: an undated event renders as a plain pill in the undated + * bucket (out of clustering scope), so including it would scatter its dated letters into orphaned + * cross-year cards detached from that pill (#7). + * + * An event with an empty/whitespace title is skipped too — clustering under it would render a + * label-less `✉` mystery card; its letters stay loose instead (#8). + */ +export function buildEventLookup(timeline: TimelineDTO): Map { + const lookup = new Map(); + const collect = (entries: TimelineEntryDTO[]) => { + for (const entry of entries) { + const title = entry.title?.trim(); + if (entry.kind === 'EVENT' && entry.eventId && entry.type !== 'HISTORICAL' && title) { + lookup.set(entry.eventId, title); + } + } + }; + for (const band of timeline.years) collect(band.entries); + return lookup; +} + +/** + * Splits one year's `LETTER` entries into event clusters and the loose remainder. A letter joins + * the cluster keyed by its `linkedEventId` IFF that id is set AND present in `eventLookup` + * (filter-then-cluster, REQ-007/008); every other letter is loose and stays in the chronological + * flow (REQ-006). Clusters keep first-seen order; each letter appears in exactly one place. + */ +export function splitYearLetters( + letters: TimelineEntryDTO[], + eventLookup?: Map +): SplitLetters { + const byEvent = new Map(); + const clusters: EventCluster[] = []; + const loose: TimelineEntryDTO[] = []; + + for (const letter of letters) { + const eventId = letter.linkedEventId; + const title = eventId != null ? eventLookup?.get(eventId) : undefined; + if (eventId != null && title !== undefined) { + let cluster = byEvent.get(eventId); + if (!cluster) { + cluster = { eventId, title, letters: [] }; + byEvent.set(eventId, cluster); + clusters.push(cluster); + } + cluster.letters.push(letter); + } else { + loose.push(letter); + } + } + + return { clusters, loose, byEvent }; +} diff --git a/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts b/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts new file mode 100644 index 00000000..35440b96 --- /dev/null +++ b/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const timelineDir = dirname(fileURLToPath(import.meta.url)); + +/** + * REQ-010 / CWE-79: inline event clustering renders curator event titles and import-derived + * letter titles + sender/receiver text through every component under lib/timeline (the reused + * LetterCard, the new EventCluster card, the existing pills/bands/strip). That text must always + * render through Svelte's default `{...}` escaping — never `{@html}`. This grep gate fails loudly + * the moment any timeline component reaches for the raw-HTML directive. + */ +describe('lib/timeline never uses {@html} (REQ-010)', () => { + it('no timeline component contains the raw-HTML directive', () => { + const components = readdirSync(timelineDir).filter((file) => file.endsWith('.svelte')); + expect(components.length).toBeGreaterThan(0); + const offenders = components.filter((file) => + readFileSync(join(timelineDir, file), 'utf8').includes('{@html') + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/frontend/src/routes/zeitstrahl/+page.svelte b/frontend/src/routes/zeitstrahl/+page.svelte index 759b3340..efd65fbe 100644 --- a/frontend/src/routes/zeitstrahl/+page.svelte +++ b/frontend/src/routes/zeitstrahl/+page.svelte @@ -60,7 +60,7 @@ const metaLine = $derived.by(() => { : m.timeline_events_count({ count: meta.eventCount }) ); } - segments.push(m.timeline_grouping_date()); + // REQ-011: the toggle-free chronological view carries no grouping segment. return segments.join(' · '); }); diff --git a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts index 3f8a8d7b..3a900329 100644 --- a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts +++ b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts @@ -43,7 +43,7 @@ describe('/zeitstrahl page', () => { expect(canvas?.querySelector('ol')).not.toBeNull(); }); - it('renders the meta sub-line with range, counts, and grouping (REQ-002)', () => { + it('renders the meta sub-line with range and counts, no grouping segment (REQ-011)', () => { const dto = makeTimelineDTO({ years: [ makeYear(1909, [ @@ -59,7 +59,8 @@ describe('/zeitstrahl page', () => { expect(sub?.textContent).toContain('1909–1924'); expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 })); expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 })); - expect(sub?.textContent).toContain(m.timeline_grouping_date()); + // REQ-011: the toggle-free view drops the grouping meta segment. + expect(sub?.textContent).not.toContain('Gruppierung'); }); it('omits the range segment when there are no year bands (REQ-002)', () => { @@ -84,7 +85,7 @@ describe('/zeitstrahl page', () => { const sub = document.querySelector('[data-testid="timeline-meta"]'); expect(sub).not.toBeNull(); expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 })); - expect(sub?.textContent).toContain(m.timeline_grouping_date()); + expect(sub?.textContent).not.toContain('Gruppierung'); }); it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => {