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
3 changed files with 5 additions and 16 deletions

View File

@@ -229,14 +229,9 @@ jobs:
name: Backend Unit Tests
runs-on: ubuntu-latest
env:
# CI runs against the root-server Docker daemon (29.x). This API pin is a harmless
# carry-over from the old NAS runner (Docker 24.x, max API 1.43); safe to drop later.
DOCKER_API_VERSION: "1.43"
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
DOCKER_HOST: unix:///var/run/docker.sock
# Ryuk (Testcontainers' out-of-process reaper) is intentionally LEFT ENABLED so it
# removes each run's containers after the JVM exits. Disabling it forced the in-JVM
# reaper, which hung at JVM shutdown and leaked Postgres containers run-over-run until
# the daemon degraded and the fork timed out at teardown — see #848.
TESTCONTAINERS_RYUK_DISABLED: "true"
steps:
- uses: actions/checkout@v4

View File

@@ -369,12 +369,6 @@
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
<!-- Grace period after the test JVM calls System.exit(0). The 30s default is too
short: the single reused fork closes ~32 cached Spring contexts at shutdown,
each tearing down a Testcontainers Postgres + HikariCP pool, which overruns 30s
and makes Surefire kill the fork (BUILD FAILURE despite 0 test failures). This is
a different knob from forkedProcessTimeoutInSeconds above. See issue #848. -->
<forkedProcessExitTimeoutInSeconds>120</forkedProcessExitTimeoutInSeconds>
<systemPropertyVariables>
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
</systemPropertyVariables>

View File

@@ -9515,9 +9515,9 @@
}
},
"node_modules/vite": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",