Compare commits

..

22 Commits

Author SHA1 Message Date
Marcel
621248f941 refactor(timeline): extract shared EventHeader for pill + event-card
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 8m52s
CI / OCR Service Tests (pull_request) Successful in 2m58s
CI / Backend Unit Tests (pull_request) Failing after 13m32s
CI / fail2ban Regex (pull_request) Successful in 1m52s
CI / Semgrep Security Scan (pull_request) Successful in 43s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m51s
SDD Gate / RTM Check (pull_request) Successful in 26s
SDD Gate / Contract Validate (pull_request) Successful in 40s
SDD Gate / Constitution Impact (pull_request) Successful in 31s
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-16 13:38:21 +02:00
Marcel
c1bb652aaf 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-16 13:38:21 +02:00
Marcel
213fea3c74 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-16 13:38:21 +02:00
Marcel
9a8b4ff6d0 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-16 13:38:21 +02:00
Marcel
646b3c125e 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-16 13:38:21 +02:00
Marcel
057b1d6131 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-16 13:38:21 +02:00
Marcel
e4536b5b0c 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-16 13:38:21 +02:00
Marcel
c23ff6026e 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-16 13:38:21 +02:00
Marcel
5870d244fc 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-16 13:38:21 +02:00
Marcel
4b965e655e 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-16 13:38:21 +02:00
Marcel
d9e431cd12 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-16 13:38:21 +02:00
Marcel
2151cfad18 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-16 13:38:21 +02:00
Marcel
7ccc4c8896 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-16 13:38:21 +02:00
Marcel
9b68ec31f9 test(timeline): add the {@html} grep gate; docs(rtm): trace #850 REQ-001..013
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-16 13:38:21 +02:00
Marcel
6834381cb9 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-16 13:38:21 +02:00
Marcel
2421265e26 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-16 13:38:21 +02:00
Marcel
05ad2ac3fc 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-16 13:38:21 +02:00
Marcel
be44474f8a 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-16 13:38:21 +02:00
Marcel
98530ab9df 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-16 13:38:21 +02:00
Marcel
6a56540cf7 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-16 13:38:21 +02:00
Marcel
109202246e fix(deps): bump vite 7.3.3 -> 7.3.5 to clear the high-severity audit gate
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 7m30s
CI / OCR Service Tests (pull_request) Successful in 37s
CI / Backend Unit Tests (pull_request) Failing after 12m40s
CI / fail2ban Regex (pull_request) Successful in 1m46s
CI / Semgrep Security Scan (pull_request) Successful in 35s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m49s
SDD Gate / RTM Check (pull_request) Successful in 31s
SDD Gate / Contract Validate (pull_request) Successful in 41s
SDD Gate / Constitution Impact (pull_request) Successful in 29s
CI / Unit & Component Tests (push) Successful in 7m5s
CI / OCR Service Tests (push) Successful in 48s
CI / Backend Unit Tests (push) Failing after 12m53s
CI / fail2ban Regex (push) Successful in 1m44s
CI / Semgrep Security Scan (push) Successful in 35s
CI / Compose Bucket Idempotency (push) Successful in 1m48s
vite 7.3.3 carries two high-severity advisories (GHSA-v6wh-96g9-6wx3
NTLMv2 UNC disclosure, GHSA-fx2h-pf6j-xcff server.fs.deny bypass), both
flagged by the CI gate `npm audit --audit-level=high --omit=dev`. 7.3.5
is in-range of the existing `^7.3.3` constraint, so this is a
lockfile-only patch with no package.json change. Gate now exits 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:16:58 +02:00
273a97046a fix(ci): re-enable Testcontainers Ryuk to stop the backend fork shutdown hang (#848) (#849)
Some checks failed
CI / Unit & Component Tests (push) Failing after 39s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 5m57s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 24s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
nightly / deploy-staging (push) Successful in 4m49s
nightly / npm-audit (push) Failing after 18s
Renovate / renovate (push) Failing after 23s
Fixes #848.

## Symptom

CI `Backend Unit Tests` goes red despite **all tests passing**: after the last test, the fork hangs at JVM shutdown and Surefire reports `There was a timeout in the fork` → `BUILD FAILURE`.

## Root cause (corrected after investigation)

My first theory (slow shutdown needs a bigger timeout) was **wrong** — raising `forkedProcessExitTimeoutInSeconds` 30→120 only delayed the kill by ~90s (total time 12:35 → 14:04), proving an *indefinite* hang, not slowness.

The real cause is **Testcontainers teardown with Ryuk disabled**:
- The job set `TESTCONTAINERS_RYUK_DISABLED: "true"` (carry-over from the old NAS runner).
- With Ryuk off, containers are reaped by the **in-JVM `JVMHookResourceReaper`** at shutdown. That reaper crashes (`NotFoundException`) and **leaks containers run-over-run**.
- The run boots ~30 per-context Spring contexts (`PostgresContainerConfig` is a per-context `@Bean`), so ~30 Postgres containers are torn down in-JVM at shutdown.
- As leaks accumulate on the runner, per-run teardown degrades until the fork hangs at shutdown → fork timeout. **The server had 21 orphaned `postgres:16-alpine`/`minio` containers up to 5 weeks old**; manually killing them is what restored CI before (a recurring pattern).

Environment confirmed via `ssh root@raddatz.cloud`: CI now runs on a root server with **Docker 29.4.3** (8 CPU, 62 GB, socket access) — so the original reason to disable Ryuk no longer applies, and Docker is *not* slow.

## Change

1. **Re-enable Ryuk** (remove `TESTCONTAINERS_RYUK_DISABLED`) — Ryuk reaps each run's containers out-of-process after the JVM exits, so they never accumulate. Automates the manual "kill all testcontainers."
2. Keep `forkedProcessExitTimeoutInSeconds=120` as a harmless backstop.
3. Drop the stale "NAS runner" comment on `DOCKER_API_VERSION`.

Operational: the 21 leaked containers were already removed from the server (by `org.testcontainers=true` label; real services untouched), giving immediate relief.

## Validation

Validated by this PR's CI run on the real runner (watching it). If Ryuk can't start in the runner's docker-outside-docker setup, the integration tests fail fast and I revert — fallback is a singleton Postgres container.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #849
2026-06-15 20:53:58 +02:00
3 changed files with 16 additions and 5 deletions

View File

@@ -229,9 +229,14 @@ jobs:
name: Backend Unit Tests
runs-on: ubuntu-latest
env:
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
# 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_HOST: unix:///var/run/docker.sock
TESTCONTAINERS_RYUK_DISABLED: "true"
# 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.
steps:
- uses: actions/checkout@v4

View File

@@ -369,6 +369,12 @@
<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.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",