Compare commits

...

20 Commits

Author SHA1 Message Date
Marcel
bcf95e4399 refactor(timeline): extract shared EventHeader for pill + event-card
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 47s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Successful in 6m17s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 19s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
EventCluster's same-year header re-implemented EventPill's glyph circle,
serif title, provenance subtitle, and the curator edit anchor near-
verbatim — the third copy of that markup. They now share a single
EventHeader component (glyph via GlyphLabel, title, `{date} · provenance`
subtitle, optional sr-only letter count, and the canEditEvent-gated edit
pencil); EventPill keeps only its pill border, EventCluster only its card
chrome. Second half of review finding #5 (Architect-1). No behavior
change — EventPill/EventCluster/YearBand/TimelineView specs stay green.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:43:55 +02:00
Marcel
d450f97bff refactor(timeline): single-source the curator edit gate via canEditEvent
The security-relevant edit-affordance gate (canWrite && !derived && eventId
!= null) was copied into EventPill, WorldBand, and EventCluster — three
places for one load-bearing contract, inviting drift. It now lives once as
canEditEvent(entry, canWrite) in eventCardConfig, and all three call it. No
behavior change (HISTORICAL is never derived, so WorldBand's gate is
unchanged). First half of review finding #5 (Architect-1).

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:36:49 +02:00
Marcel
81e0dfb9e6 fix(timeline): interleave cross-year cards at their earliest letter
Cross-year clusters were appended after every event and loose letter, so a
band with a loose November letter plus February letters linked to another
year's event rendered the February ✉ card BELOW the November letter —
earlier-dated content sitting visually below later-dated content, breaking
the strict-time reading the band guarantees.

A cross-year cluster (no same-year EVENT anchor in this band) now emits its
card at the position of its earliest linked letter, in the band's
chronological order. Closes the spec gap pinned as REQ-015. Fixes review
finding #1.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:31:03 +02:00
Marcel
70a76904e1 refactor(timeline): O(1) lookups in YearBand row assembly
`loose.includes(entry)` ran once per LETTER inside the band loop — O(L²)
on a dense band of hundreds of loose letters, recomputed on every layer
re-render. splitYearLetters now also returns its `byEvent` map, so a
letter's disposition is `byEvent.has(linkedEventId)` and an event's card
is `byEvent.get(eventId)`, both O(1); `consumed` is a plain object. No
behavior change. Fixes review finding #3.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:27:17 +02:00
Marcel
a68f7ee527 fix(timeline): give the event-card letter count a screen-reader label
The bare "· {count}" spans in both the same-year and cross-year headers
announced as "· 2" with no context. Each now pairs the aria-hidden visible
count with an sr-only "{count} Briefe" via a new Paraglide key
(timeline_cluster_letter_count, present in de/en/es). Fixes review
finding #6 (count half).

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:23:24 +02:00
Marcel
30384fa53b fix(timeline): label the cross-year ✉ glyph for screen readers
The cross-year card header emitted a bare aria-hidden ✉ with no sr-only
label, unlike the same-year header and LetterCard — a screen-reader user
heard only the title with no cue that this is a letter group. It now uses
the shared GlyphLabel (✉ + sr-only "Brief"). Fixes review finding #6
(glyph half).

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:18:31 +02:00
Marcel
a9027ceaf7 fix(timeline): keep a compact letter's date unless the title embeds it
`showDate = !compact || !entry.title` dropped the date chip for ANY titled
compact letter. But titles are free-form OCR/import text — a letter titled
"Brief an Mutter" lost its month/day entirely, and inside an event card
the band frames only the year. The chip now drops only when the formatted
date actually appears in the title (e.g. "H-0023 – 6. Juli 1916"), so the
row-height win holds where valid and no information is lost otherwise.

The spec that asserted the date vanishes for any title is rewritten to the
correct contract, plus an inverse test. Fixes review finding #4.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:15:24 +02:00
Marcel
4e704ae4f9 fix(timeline): skip empty-title events in the cluster lookup
A titleless or whitespace-only event stored `''` in the lookup, so its
letters still clustered and rendered a label-less `✉` mystery card. The
lookup now skips events whose trimmed title is empty — those letters stay
loose. Fixes review finding #8.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:11:23 +02:00
Marcel
8cc11aecb5 fix(timeline): exclude undated events from the cluster lookup
buildEventLookup also collected `timeline.undated`, so an undated curated
event — which renders as a plain EventPill in the undated bucket, out of
clustering scope — still seeded clusters: its dated linked letters
scattered into year bands and each collapsed into a ✉ cross-year card
with no edit link and no spatial tie to the pill, showing the event title
twice with no relationship.

Only year-band events are collected now. Fixes review finding #7.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:08:39 +02:00
Marcel
0fc7ef5d3b fix(timeline): keep HISTORICAL events out of inline clustering
buildEventLookup keyed on `kind === 'EVENT' && eventId` with no type
check, so a HISTORICAL curated event with ≥1 linked letter entered the
lookup and rendered as a mint EventCluster card — silently downgrading
from the full-width WorldBand that #779 REQ-009 mandates ("world-bands
render exactly as before").

The lookup now excludes `type === 'HISTORICAL'`, so a world event always
keeps its WorldBand and its letters stay loose chronological. Closes the
spec gap pinned as REQ-014. Fixes review finding #2.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:06:01 +02:00
Marcel
b5319876eb perf(timeline): bound the letter→event link pass to filtered events
resolveLetterEventLinks iterated `eventRepository.findAll()` and touched
the lazy `documents` collection on every event — including events removed
by the type/person/generation/year filters and never shown. On a large
archive that hydrates join rows that are immediately discarded.

It now runs over the events that survived the filter (collected once in
the existing event loop). A letter whose only linking event was filtered
out links to nothing, which matches the frontend's filter-then-cluster
(the letter renders loose either way). Fixes review finding #10 (DevOps).

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:00:52 +02:00
Marcel
5a21843cfc fix(timeline): resolve a multi-event letter link deterministically
A letter whose document is in more than one curated event's `documents`
set was linked by `putIfAbsent` over `eventRepository.findAll()`, whose
iteration order JPA does not guarantee — so the same letter could cluster
under a different event across re-seeds/VACUUM with no data change.

resolveLetterEventLinks now runs over a stably-ordered copy (earliest
event date, undated last, then lowest id), so the link is a deterministic
property of the data. Fixes review finding #9 (Architect-3).

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:57:07 +02:00
Marcel
179ada131f docs(rtm): add REQ-014/REQ-015 rows for #850
Two spec gaps the Requirements Engineer flagged on PR #851 are now pinned
as requirements in the #850 issue body and traced here (Status Planned,
flipped to Done as the tasks land):

- REQ-014: a HISTORICAL curated event keeps its full-width WorldBand and
  never clusters into a card, even with linked letters.
- REQ-015: a cross-year card sits at its earliest linked letter's
  chronological position, never appended after later-dated loose letters.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:51:59 +02:00
Marcel
bf73d8de55 test(timeline): add the {@html} grep gate; docs(rtm): trace #850 REQ-001..013
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 42s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 6m14s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 27s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
The grep gate fails if any lib/timeline component reaches for the raw-HTML
directive (CWE-79, REQ-010). The RTM gains thirteen rows tracing every #850
requirement to its implementation file(s) and test(s), Status Done.

Refs #850
2026-06-15 20:49:28 +02:00
Marcel
8d37ee4ffb feat(timeline): thread event clustering through TimelineView; drop the grouping meta segment
TimelineView builds the event lookup once over the whole timeline and threads
it (plus canWrite) to every YearBand, so a curated event's letters cluster
under it inline. The /zeitstrahl meta-line drops its 'Gruppierung: Datum'
segment (toggle-free view, REQ-011); the now-unused timeline_grouping_date
key is removed from de/en/es and the messages parity guard, which now asserts
the new show-more/less keys.

Refs #850
2026-06-15 20:45:45 +02:00
Marcel
f1be944b3b feat(timeline): cluster event letters inline in YearBand, loose letters stay chronological
A curated event with same-year linked letters renders as one EventCluster
card (no separate pill); a cluster whose event lives in another band renders
as a cross-year text-header card. Letterless/derived/world events stay plain
pills/world-bands. Loose letters keep the alternating left/right layout and
fold into one density strip past 12 — and the layout + strip count ONLY the
loose letters, so a clustered letter never re-appears loose.

Refs #850
2026-06-15 20:41:52 +02:00
Marcel
a6af6e18ec feat(timeline): add the EventCluster card + show-more/less i18n
A curated event with linked letters renders as one contained card: the
event is the header (accent glyph, title, date · provenance, count, and a
curator edit link), its letters sit inside as compact .lcard.ev cards. The
first CLUSTER_PREVIEW (5) show, then a keyboard-operable show-more/less
toggle reveals the rest. A cross-year card (no event prop) gets a plain
'✉ title' text header with no edit link. Titles render through default
{...} escaping. Adds timeline_bucket_show_more/less keys to de/en/es.

Refs #850
2026-06-15 20:37:23 +02:00
Marcel
4dcbd05477 feat(timeline): add the event-clustering split helper
buildEventLookup maps each curated event in the (already layer-filtered)
timeline to its title; splitYearLetters partitions a year's letters into
event clusters (keyed by a linkedEventId present in the lookup) and the
loose chronological remainder. A letter linking to a filtered-out event
falls back to loose (filter-then-cluster); each letter appears once.

Refs #850
2026-06-15 20:33:49 +02:00
Marcel
e04a9990d4 feat(timeline): add compact + event variants to LetterCard
The event variant adds the .lcard.ev marker for letters living inside a
contained event card; the compact variant tightens the row (py-1, text-xs
title) and drops the redundant date chip when the title already embeds the
date. suppressTagChip lets a caller that already conveys the topic hide the
per-letter root-tag chip. Plain Datum letters are unchanged.

Refs #850
2026-06-15 20:32:01 +02:00
Marcel
d01f2e2edf feat(timeline): compute a letter's linkedEventId in TimelineService
For each LETTER entry, resolve the curated event whose documents set
contains the letter, in one batched pass over the events already loaded
(no per-letter query, no new column). The DTO gains a nullable
linkedEventId; non-letter entries keep null.

Refs #850
2026-06-15 20:29:57 +02:00
29 changed files with 1266 additions and 92 deletions

View File

@@ -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 |

View File

@@ -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
) {
}

View File

@@ -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;

View File

@@ -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),

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -2467,6 +2467,8 @@ export interface components {
rootTagId?: string;
rootTagName?: string;
rootTagColor?: string;
/** Format: uuid */
linkedEventId?: string;
};
TimelineYearDTO: {
/** Format: int32 */

View File

@@ -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)".

View 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>

View 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)>');
});
});

View 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}

View 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();
});
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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}

View File

@@ -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');
});
});

View File

@@ -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">

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}

View 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]);
});
});

View 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 };
}

View 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([]);
});
});

View File

@@ -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>

View File

@@ -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('19091924');
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)', () => {