Compare commits
20 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcf95e4399 | ||
|
|
d450f97bff | ||
|
|
81e0dfb9e6 | ||
|
|
70a76904e1 | ||
|
|
a68f7ee527 | ||
|
|
30384fa53b | ||
|
|
a9027ceaf7 | ||
|
|
4e704ae4f9 | ||
|
|
8cc11aecb5 | ||
|
|
0fc7ef5d3b | ||
|
|
b5319876eb | ||
|
|
5a21843cfc | ||
|
|
179ada131f | ||
|
|
bf73d8de55 | ||
|
|
8d37ee4ffb | ||
|
|
f1be944b3b | ||
|
|
a6af6e18ec | ||
|
|
4dcbd05477 | ||
|
|
e04a9990d4 | ||
|
|
d01f2e2edf |
@@ -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 |
|
||||
|
||||
@@ -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 (#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.
|
||||
*
|
||||
* <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,13 +80,20 @@ public class TimelineService {
|
||||
// Resolve generation person IDs once — used across all three layers
|
||||
Set<UUID> 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<TimelineEvent> allEvents = eventRepository.findAll();
|
||||
|
||||
// ── curated events ───────────────────────────────────────────────────
|
||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
||||
for (TimelineEvent ev : eventRepository.findAll()) {
|
||||
List<TimelineEvent> 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<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
|
||||
Map<UUID, UUID> 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<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 +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<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();
|
||||
|
||||
// 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<TimelineEvent> ordered = events.stream()
|
||||
.sorted(Comparator
|
||||
.comparing(TimelineEvent::getEventDate,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(TimelineEvent::getId))
|
||||
.toList();
|
||||
|
||||
Map<UUID, UUID> eventByDocId = new HashMap<>();
|
||||
for (TimelineEvent ev : ordered) {
|
||||
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,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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2467,6 +2467,8 @@ export interface components {
|
||||
rootTagId?: string;
|
||||
rootTagName?: string;
|
||||
rootTagColor?: string;
|
||||
/** Format: uuid */
|
||||
linkedEventId?: string;
|
||||
};
|
||||
TimelineYearDTO: {
|
||||
/** Format: int32 */
|
||||
|
||||
@@ -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)".
|
||||
|
||||
104
frontend/src/lib/timeline/EventCluster.svelte
Normal file
104
frontend/src/lib/timeline/EventCluster.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import GlyphLabel from './GlyphLabel.svelte';
|
||||
import EventHeader from './EventHeader.svelte';
|
||||
import { entryKey } from './entryKey';
|
||||
import { CLUSTER_PREVIEW } from './eventClustering';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* A curated event with linked letters, rendered as one contained card: the event IS the card's
|
||||
* header (so its title reads once — never also as a floating pill, #850 REQ-002), and its letters
|
||||
* sit inside as compact `.lcard.ev` cards.
|
||||
*
|
||||
* - Same-year event (`event` given): the shared EventHeader carries the accent glyph + sr-only
|
||||
* label, the title, a `{date} · {kuratiert|abgeleitet}` subtitle, the letter count, and — for a
|
||||
* curator on a curated event — an edit link to `/zeitstrahl/events/{eventId}/edit` (REQ-002).
|
||||
* - Cross-year (`title` given, no `event`): a plain `✉ {title}` text header, no edit link, no pill
|
||||
* chrome — it holds that other year's linked letters (REQ-004).
|
||||
*
|
||||
* A card shows its first {@link CLUSTER_PREVIEW} letters, then a keyboard-operable show-more/less
|
||||
* toggle reveals/collapses the rest instead of flooding the timeline (REQ-003).
|
||||
*/
|
||||
let {
|
||||
letters,
|
||||
event = undefined,
|
||||
title = '',
|
||||
canWrite = false
|
||||
}: {
|
||||
letters: TimelineEntryDTO[];
|
||||
/** The same-year curated event whose letters this card holds — renders as the header. */
|
||||
event?: TimelineEntryDTO;
|
||||
/** Header label for a cross-year card (no `event`). */
|
||||
title?: string;
|
||||
canWrite?: boolean;
|
||||
} = $props();
|
||||
|
||||
const count = $derived(letters.length);
|
||||
|
||||
// First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every
|
||||
// card into the timeline.
|
||||
let expanded = $state(false);
|
||||
const visible = $derived(expanded ? letters : letters.slice(0, CLUSTER_PREVIEW));
|
||||
const hiddenCount = $derived(letters.length - CLUSTER_PREVIEW);
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="my-3 overflow-hidden rounded-md border border-l-2 border-line border-l-brand-mint bg-surface shadow-sm"
|
||||
data-testid="event-card"
|
||||
>
|
||||
{#if event}
|
||||
<!-- A same-year curated event IS the card header (the shared EventHeader) — its title reads
|
||||
once here, never also as a floating pill (REQ-002); the edit pencil uses the single
|
||||
canEditEvent gate (REQ-010, #850 finding #5). -->
|
||||
<header
|
||||
data-testid="event-header"
|
||||
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
|
||||
>
|
||||
<EventHeader entry={event} canWrite={canWrite} count={count} />
|
||||
</header>
|
||||
{:else}
|
||||
<!-- Cross-year card (REQ-004): the same event's letters in another year band get a plain
|
||||
✉ text header — no pill chrome, no edit link. -->
|
||||
<header
|
||||
data-testid="event-header"
|
||||
class="flex items-center gap-2 border-b border-line px-3 py-2"
|
||||
>
|
||||
<span class="font-serif text-sm font-bold whitespace-pre-line text-ink">
|
||||
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
|
||||
{title}
|
||||
</span>
|
||||
<span data-testid="event-count" class="font-sans text-xs text-ink-3">
|
||||
<span aria-hidden="true">· {count}</span>
|
||||
<span class="sr-only">{m.timeline_cluster_letter_count({ count })}</span>
|
||||
</span>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="px-3 py-2">
|
||||
<ul class="space-y-1.5">
|
||||
{#each visible as letter (entryKey(letter))}
|
||||
<li>
|
||||
<LetterCard entry={letter} variant="event" compact={true} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hiddenCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bucket-show-more"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{expanded
|
||||
? m.timeline_bucket_show_less()
|
||||
: m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
129
frontend/src/lib/timeline/EventCluster.svelte.spec.ts
Normal file
129
frontend/src/lib/timeline/EventCluster.svelte.spec.ts
Normal file
@@ -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> = {}): 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: '<img src=x onerror=alert(1)>' })
|
||||
});
|
||||
expect(document.querySelector('[data-testid="event-card"] img')).toBeNull();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
69
frontend/src/lib/timeline/EventHeader.svelte
Normal file
69
frontend/src/lib/timeline/EventHeader.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import GlyphLabel from './GlyphLabel.svelte';
|
||||
import { getAccentConfig, canEditEvent } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* The shared header for a curated or derived timeline event — the accent glyph circle, the title,
|
||||
* and the `{date} · {kuratiert|abgeleitet}` subtitle, plus a curator edit pencil gated by the
|
||||
* single canEditEvent() contract. Rendered by EventPill (inside the floating axis pill) and by
|
||||
* EventCluster (as a same-year event-card header), so the glyph/title/subtitle markup and the
|
||||
* security-relevant edit gate live in one place (#850 finding #5). It renders three sibling nodes
|
||||
* (glyph circle, text block, optional edit pencil) into the parent's flex row — the parent owns
|
||||
* the wrapper (pill vs card header). An optional letter `count` appends a screen-reader-labeled
|
||||
* "· {count}" for the event-card case.
|
||||
*/
|
||||
let {
|
||||
entry,
|
||||
canWrite = false,
|
||||
count = undefined
|
||||
}: { entry: TimelineEntryDTO; canWrite?: boolean; count?: number } = $props();
|
||||
|
||||
const config = $derived(getAccentConfig(entry));
|
||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||
// Provenance reads off entry.derived: a derived life-event is "abgeleitet", a curated event
|
||||
// "kuratiert"; the date is an optional prefix so an undated event still reads the provenance.
|
||||
const provenance = $derived(
|
||||
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
||||
);
|
||||
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
|
||||
const canEdit = $derived(canEditEvent(entry, canWrite));
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent === 'curated'
|
||||
? 'bg-brand-mint text-brand-navy'
|
||||
: 'bg-brand-navy text-brand-mint'}"
|
||||
>
|
||||
<GlyphLabel glyph={config.glyph} label={config.label} />
|
||||
</span>
|
||||
<span class="min-w-0 text-left">
|
||||
{#if entry.title}
|
||||
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
|
||||
>{entry.title}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="block font-sans text-xs text-ink-3">
|
||||
{subtitle}
|
||||
{#if count !== undefined}
|
||||
<span data-testid="event-count">
|
||||
<span aria-hidden="true">· {count}</span>
|
||||
<span class="sr-only">{m.timeline_cluster_letter_count({ count })}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<a
|
||||
data-testid="event-edit"
|
||||
href="/zeitstrahl/events/{entry.eventId}/edit"
|
||||
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
66
frontend/src/lib/timeline/EventHeader.svelte.spec.ts
Normal file
66
frontend/src/lib/timeline/EventHeader.svelte.spec.ts
Normal file
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,21 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import EventHeader from './EventHeader.svelte';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
|
||||
/**
|
||||
* Centered axis pill for a derived life-event or a curated PERSONAL event
|
||||
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
|
||||
* (REQ-018). An edit affordance shows only for a curated event with an eventId
|
||||
* (never derived, never null — REQ-008) and only for a curator who holds
|
||||
* WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The
|
||||
* gate is UX only; the real boundary is the #781 route guard + backend permission.
|
||||
* (REQ-007/008). The pill border keys off the accent (curated = mint, derived =
|
||||
* navy); its glyph, title, subtitle, and curator edit pencil are the shared
|
||||
* EventHeader, so the edit gate (canEditEvent) lives in one place — #842
|
||||
* REQ-005/007/008, #850 finding #5. The gate is UX only; the real boundary is the
|
||||
* #781 route guard + backend permission.
|
||||
*/
|
||||
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
|
||||
|
||||
const config = $derived(getAccentConfig(entry));
|
||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||
// Provenance reads off entry.derived (not the accent): a derived life-event is
|
||||
// "abgeleitet", a curated PERSONAL event is "kuratiert" (REQ-007).
|
||||
const provenance = $derived(
|
||||
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
||||
);
|
||||
// Provenance always shows; the date is an optional prefix so an undated event
|
||||
// still reads "abgeleitet"/"kuratiert" (REQ-007).
|
||||
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
|
||||
const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center">
|
||||
@@ -36,32 +25,6 @@ const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
|
||||
? 'border-2 border-brand-mint'
|
||||
: 'border border-brand-navy'}"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent ===
|
||||
'curated'
|
||||
? 'bg-brand-mint text-brand-navy'
|
||||
: 'bg-brand-navy text-brand-mint'}"
|
||||
>
|
||||
<span aria-hidden="true">{config.glyph}</span>
|
||||
<span class="sr-only">{config.label}</span>
|
||||
</span>
|
||||
<span class="text-left">
|
||||
{#if entry.title}
|
||||
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
|
||||
>{entry.title}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="block font-sans text-xs text-ink-3">{subtitle}</span>
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<a
|
||||
data-testid="event-edit"
|
||||
href="/zeitstrahl/events/{entry.eventId}/edit"
|
||||
class="ml-1 rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<EventHeader entry={entry} canWrite={canWrite} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<a
|
||||
href="/documents/{entry.documentId}"
|
||||
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
|
||||
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
class="lcard rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
class:py-2={!compact}
|
||||
class:py-1={compact}
|
||||
class:ev={isEventVariant}
|
||||
class:compact={compact}
|
||||
>
|
||||
{#if entry.title}
|
||||
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
|
||||
interpolated into the escaped user title; the title keeps its own
|
||||
pre-line span for multi-line OCR text (REQ-008/016/021). -->
|
||||
<span class="font-serif text-sm font-bold break-words text-ink">
|
||||
<span
|
||||
class="font-serif font-bold break-words text-ink"
|
||||
class:text-sm={!compact}
|
||||
class:text-xs={compact}
|
||||
>
|
||||
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
|
||||
<span class="whitespace-pre-line">{entry.title}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
|
||||
<span class="font-sans text-xs break-words text-ink-3" class:mt-0.5={!compact}>
|
||||
<span class="font-serif whitespace-pre-line">{sender}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span class="font-serif whitespace-pre-line">{receiver}</span>
|
||||
{#if dateLabel}
|
||||
{#if dateLabel && showDate}
|
||||
<span data-testid="letter-date"> · {dateLabel}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if entry.rootTagName}
|
||||
{#if entry.rootTagName && !suppressTagChip}
|
||||
<!-- The primary root-tag chip sits on its own line beneath the meta line
|
||||
(#835 §3); absent when the letter has no tag (REQ-005). -->
|
||||
(#835 §3); absent when the letter has no tag (REQ-006), and suppressed when
|
||||
the caller already conveys the topic (suppressTagChip). -->
|
||||
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <main> — 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<Row[]>(() => {
|
||||
@@ -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}`)}
|
||||
<li>
|
||||
{#if row.t === 'band'}
|
||||
<YearBand year={row.year} canWrite={canWrite} />
|
||||
<YearBand year={row.year} canWrite={canWrite} eventLookup={eventLookup} />
|
||||
{:else}
|
||||
<GapSpan from={row.from} to={row.to} />
|
||||
{/if}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { getAccentConfig, canEditEvent } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
@@ -26,9 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
|
||||
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
|
||||
// always trails the subtitle as plain text — never a second pill (REQ-009).
|
||||
const historical = $derived(m.timeline_layer_historical_suffix());
|
||||
// A HISTORICAL event is never derived, so the edit gate is just the curator
|
||||
// flag plus a real eventId (#842 REQ-006/008).
|
||||
const canEdit = $derived(canWrite && entry.eventId != null);
|
||||
// A HISTORICAL event is never derived, so canEditEvent's derived check is a
|
||||
// no-op here — the gate is the curator flag plus a real eventId (#842 REQ-006/008).
|
||||
const canEdit = $derived(canEditEvent(entry, canWrite));
|
||||
</script>
|
||||
|
||||
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
|
||||
|
||||
@@ -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 <section> with a sticky <h2> (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<string, string>;
|
||||
} = $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<Record<string, true>>(() => {
|
||||
const ids: Record<string, true> = {};
|
||||
for (const entry of year.entries) {
|
||||
if (entry.kind === 'EVENT' && entry.eventId) ids[entry.eventId] = true;
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
const emitted: Record<string, true> = {};
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-2">
|
||||
@@ -56,20 +134,27 @@ const rows = $derived.by<Row[]>(() => {
|
||||
</h2>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
{#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'}
|
||||
<WorldBand entry={row.entry} canWrite={canWrite} />
|
||||
{:else}
|
||||
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||
{/if}
|
||||
{:else if row.t === 'eventcard'}
|
||||
<EventCluster
|
||||
letters={row.cluster.letters}
|
||||
event={row.event}
|
||||
title={row.cluster.title}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
{:else if row.t === 'letter'}
|
||||
<div class="letter-row" data-side={row.side}>
|
||||
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||
<LetterCard entry={row.entry} />
|
||||
</div>
|
||||
{:else}
|
||||
<YearLetterStrip letters={letters} year={year.year} />
|
||||
<YearLetterStrip letters={loose} year={year.year} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
141
frontend/src/lib/timeline/eventClustering.spec.ts
Normal file
141
frontend/src/lib/timeline/eventClustering.spec.ts
Normal file
@@ -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> = {}): 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]);
|
||||
});
|
||||
});
|
||||
88
frontend/src/lib/timeline/eventClustering.ts
Normal file
88
frontend/src/lib/timeline/eventClustering.ts
Normal file
@@ -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<string, EventCluster>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
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<string, string>
|
||||
): SplitLetters {
|
||||
const byEvent = new Map<string, EventCluster>();
|
||||
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 };
|
||||
}
|
||||
24
frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
Normal file
24
frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(' · ');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user