Commit Graph

9 Commits

Author SHA1 Message Date
49d8ab78b4 Cluster event letters inline in the chronological /zeitstrahl (no grouping toggle) (#851)
Some checks failed
CI / Unit & Component Tests (push) Successful in 7m35s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 12m42s
CI / fail2ban Regex (push) Successful in 1m50s
CI / Semgrep Security Scan (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Successful in 1m18s
Closes #850

## Summary

On `/zeitstrahl`, a curated event that has letters linked to it now renders as a contained event card — the event is the card header (accent glyph, title, `{date} · {kuratiert|abgeleitet}` subtitle, count, and a curator edit link), with its linked letters listed inside (first 5, then a keyboard-operable show-more/less toggle). Letters in a year *other* than the event's band get a lighter cross-year `✉ title` card. Every other letter stays a plain, alternating, density-folding chronological letter. There is **no grouping control** — clustering is automatic and always on. The meta-line drops its `Gruppierung: Datum` segment.

This supersedes #827: it keeps that branch's event-card clustering and the computed `linkedEventId`, and drops the toggle, the Thema mode, and the "Weitere Briefe" drawer.

## What changed

**Backend**
- `TimelineEntryDTO` gains a nullable `linkedEventId` (UUID; not `@Schema(REQUIRED)`).
- `TimelineService.resolveLetterEventLinks` resolves each letter's curated event in one batched pass over the events it already loads — no per-letter query, no new column, no Flyway migration.
- Regenerated the single `linkedEventId?` field in `api.ts`.

**Frontend**
- New `eventClustering.ts` (`buildEventLookup`, `splitYearLetters`, `CLUSTER_PREVIEW=5`) — filter-then-cluster: a letter clusters only if its `linkedEventId` is set AND present in the lookup, otherwise it stays loose.
- New `EventCluster.svelte` — the contained event card (same-year event header + edit link, or cross-year ✉ text header; first-5 + show-more).
- `LetterCard.svelte` gains `compact` + `variant='event'` (the `.lcard.ev` in-card letter).
- `YearBand.svelte` rebuilt to render event clusters inline; loose letters keep the alternating layout and density strip, and the strip counts **only** loose letters (no duplication).
- `TimelineView.svelte` builds the event lookup once and threads it + `canWrite` to each band.
- `+page.svelte` drops the grouping meta segment; the unused `timeline_grouping_date` key removed from de/en/es.
- New `timeline_bucket_show_more`/`_less` keys in all locales.
- REQ-010 `{@html}` grep gate over `lib/timeline/`.

## Tests (real runs)

- Backend `TimelineServiceTest`: **30 passed** (incl. the 2 new `linkedEventId` tests); `DerivedEventsAssemblyTest`: 17 passed; backend main sources compile.
- Frontend client sweep (`LetterCard`, `EventCluster`, `YearBand`, `TimelineView`, `zeitstrahl/page`): **81 passed** (5 files).
- Frontend server sweep (`eventClustering`, `messages`, `timeline-no-raw-html`): **18 passed** (3 files).
- `svelte-check`: no new errors in the touched files (pre-existing baseline noise elsewhere unchanged).

RTM: thirteen `REQ-001..013` rows added for #850 (feature `inline-event-clustering`), Status Done.

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

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #851
2026-06-16 14:38:09 +02:00
Marcel
9118a10e4b feat(timeline): add the "Ereignis hinzufügen" CTA to the Zeitstrahl header
Wrap the heading in a flex-wrap header so the gated add-event link drops
below the title at narrow widths instead of overflowing; the CTA targets
#781's /zeitstrahl/events/new and only shows for a WRITE_ALL viewer. Also
thread canWrite into TimelineView so a curator sees the edit pencils on
the real page. New i18n key timeline_add_event in de/en/es.

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:12:52 +02:00
Marcel
ec0e4dfa45 fix(timeline): track the meta-line counts to the filtered view
Some checks failed
CI / fail2ban Regex (push) Successful in 46s
nightly / npm-audit (push) Failing after 15s
CI / Unit & Component Tests (push) Successful in 5m21s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 5m48s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m14s
Renovate / renovate (push) Failing after 21s
The /zeitstrahl header sub-line counted the unfiltered timeline, so a
hidden layer (e.g. Letters off) still showed its entries in the totals
("1 Brief" with no letters on screen) — the documented D1 limitation.
Derive the meta from filteredTimeline so the range and letter/event
counts always match what is actually rendered. hasContent stays on the
full timeline so the filter bar and meta line still appear whenever the
archive has content.

Refs #780

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
e18282318a feat(timeline): wire the layer filter into /zeitstrahl
The route holds the three layer toggles in $state, binds them into
TimelineFilters, and derives a client-side filtered view of the SSR-loaded
timeline that it passes to TimelineView — no goto, no URL param, no extra
fetch. When the active toggles leave nothing visible it renders a calm
filtered-empty message plus a one-click reset below the still-open filter bar,
never a blank page and never the generic "no events" state. The meta-line keeps
counting the unfiltered timeline (D1 known limitation).

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
398babe584 fix(timeline): pluralize the zeitstrahl meta-line counts
A count of one rendered "1 Briefe" / "1 Ereignisse". Add _singular
companion keys (de/en/es) and select them when the count is exactly one,
following the project's _singular/_plural convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:15:11 +02:00
Marcel
9665c9c0fc fix(timeline): drop zero-count segments from the zeitstrahl meta line
A timeline with content in only one category rendered a misleading
"0 Briefe" / "0 Ereignisse" segment. Only push a count segment when its
count is greater than zero; the grouping label and (when present) the
range are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:10:37 +02:00
Marcel
15836ea9ca refactor(timeline): drop the canvas outer border (REQ-001)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m1s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 23s
SDD Gate / RTM Check (pull_request) Successful in 16s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
The page is already bg-canvas, so the frame's border was the only thing
making it visible; per review it reads cleaner without it. Keep the padded
bg-canvas surface; the timeline sits on the page without a frame line.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:39:17 +02:00
Marcel
e4da28d795 feat(timeline): frame /zeitstrahl in a canvas with a meta line (REQ-001/002)
The timeline now sits inside a bordered, rounded bg-canvas sheet. Below the
heading a sub-line composes the year range, the letter and event counts
(from timelineMeta), and the static "Gruppierung: Datum" — joined by " · "
so the range drops out when there are no year bands and the whole line is
absent for an empty timeline. Semantic tokens only; AA-legible text-xs.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:00:54 +02:00
Marcel
6f32299255 feat(timeline): add /zeitstrahl route, SSR load, and nav link
SSR-first load fetches GET /api/timeline via createApiClient (auth cookie
forwarded), no query params for the global view (REQ-001), returns { timeline }
with no client-side fetch (REQ-002); 401 -> /login, any other non-ok ->
error(status, getErrorMessage(...)), never raw JSON, no PII logged (REQ-022).
The page renders <TimelineView> under the layout's <main>. Adds the Zeitstrahl
nav link (desktop + mobile) and 'timeline' to the eslint routes boundary
allow-list so the route may import the domain.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:50:28 +02:00