Compare commits

...

19 Commits

Author SHA1 Message Date
Marcel
ce6afd3bd0 docs(rtm): trace the #827 grouped-view redesign (nesting, contrast, density)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m32s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Successful in 5m38s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
REQ-014 now nests event-clustered letters under their pill (no duplicate title);
REQ-015 keeps the bucket-header label in a fixed ink for AA contrast; new REQ-020
records the colour-rail containment + density-strip collapse that replaces the
flooding flat card list.

Refs #827 #847
2026-06-15 12:11:38 +02:00
Marcel
ea1034f9ce feat(timeline): nest Ereignis letters under their event pill, no duplicate title
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m47s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m56s
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 1m9s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 34s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
In Ereignis mode the curated event showed twice — once as its axis pill and again
as a repeated "✉ <event>" bucket header below. Letters that link to a curated event
whose pill is in the same year band now nest directly under that pill (headerless),
so the title reads once. A cluster whose pill lives in another band keeps its header,
and unlinked letters still fall to the single "Weitere Briefe" bucket. Thema mode is
unchanged (tags have no axis pill). REQ-001 holds — the pills render identically.

Refs #827
2026-06-15 12:07:39 +02:00
Marcel
23534fb077 feat(timeline): contain buckets with a colour rail and collapse oversized ones
The grouped view flooded: buckets had no visual containment (a tiny floating pill
over cards identical to the ungrouped view) and the >12-letter density collapse was
gone, so "Weitere Briefe · 325" / "Sonstiges · 10" dumped every card.

LetterBucket now binds each cluster with a coloured left rail (tag colour in Thema,
mint for an Ereignis cluster, neutral for the fallback), renders compact cards, and
— above BUCKET_DENSE_THRESHOLD (6) — collapses to the existing month-density
YearLetterStrip instead of a flood. Adds a `nested` mode (no header) for letters that
sit under their event pill, and shares the tag-colour token allow-list via tagColorVar.

Refs #827
2026-06-15 12:01:47 +02:00
Marcel
ca06293dc5 feat(timeline): add a compact LetterCard variant for in-bucket letters
Grouped-mode buckets stack many letters; the full two-line card with its own date
chip floods the view. The compact variant tightens the padding and, when the
letter has a title, drops the redundant date chip (the per-year bucket already
frames the time and these archive titles embed the date). Datum mode is untouched
— compact defaults to false.

Refs #827
2026-06-15 11:57:20 +02:00
Marcel
dd97418e24 fix(timeline): keep the Thema bucket-header label in a fixed ink, not the tag token
The tinted bucket-header chip painted the saturated --c-tag-* token AS its label
text over a 18% wash of the same token. For the light tokens that fails WCAG AA:
amber ≈3.0:1, sand ≈3.2:1, sage ≈3.4:1 (only sienna, the one the test used,
passed). Move the tint to the chip fill + dot and render the label in a fixed
dark ink so every token clears 4.5:1 while the chip still reads as tinted.

Refs #827
2026-06-15 11:54:19 +02:00
Marcel
b54a35322b docs(adr): ADR-045 + RTM rows for the #827 grouping modes
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m50s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m7s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 15s
Record the three #827 forks (client-side regroup transport, computed letter→event
link reusing timeline_event_documents, filter-then-group composition with #780)
as ADR-045, trace REQ-001..019 (+005b) into the RTM as Done, and list the new
timeline components in the frontend domain inventory.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:08:02 +02:00
Marcel
9551bbd1ca test(i18n): assert the #827 grouping + bucket keys exist in every locale
REQ-012 coverage: the new grouping/segment/bucket keys are present in de/en/es
and the pre-existing timeline_grouping_date / timeline_tag_chip_label are reused,
not re-declared.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:07:43 +02:00
Marcel
38250606d9 test(timeline): add the e2e grouping spec (zero-fetch, 320px, axe)
Mirrors the #780 layer-filter e2e for #827: switching Datum/Ereignis/Thema
issues zero extra GET /api/timeline (REQ-002), the control stays overflow-free
and ≥44px with full-word aria-labels at 320px (REQ-011), and a 320px axe pass
holds in light and dark mode (REQ-010g). Local-only like the filter e2e (E2E is
not yet in CI).

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:02:59 +02:00
Marcel
6c85f47794 test(timeline): gate the event layer identity and the {@html} ban
Add the REQ-001 structural-identity check (the event pills/world-bands render
identically across all three grouping modes — the no-VRT-harness equivalent of
the pixel-diff) and the REQ-009 grep gate (no lib/timeline component reaches for
the raw-HTML directive). Reword the BucketHeaderChip doc to describe the
directive by name so the gate stays literal.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:00:16 +02:00
Marcel
5936f3a9ae feat(timeline): wire the grouping toggle into the Zeitstrahl page
Add the grouping $state beside the #780 layer-filter state, render the
GroupingControl stacked above the filter trigger (disabled, but kept in place,
when no loose letters remain), make the meta-line grouping label track the
active mode, and thread groupingMode into TimelineView — filter-then-group,
no refetch.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:56:55 +02:00
Marcel
8be4b40e54 feat(timeline): render letter buckets in TimelineView/YearBand
Thread groupingMode through TimelineView → YearBand. TimelineView resolves the
event lookup once over the filtered view (so Ereignis clusters never reference a
filtered-out event). In non-Datum modes YearBand keeps its event pills/world-bands
identical (REQ-001) and replaces the loose letters with per-year LetterBuckets
(REQ-002/003/004); Datum keeps the original card/strip path. The undated bucket is
unchanged in every mode.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:52:48 +02:00
Marcel
bc22b2d4c9 feat(timeline): add the Datum·Ereignis·Thema grouping control
An ARIA radiogroup with roving tabindex (#827, REQ-010/011/018): three
arrow-key-navigable ≥44px segments with text labels, full-word aria-labels and
≤360px abbreviations, semantic-token colours that hold contrast in dark mode,
defaulting to Datum. When disabled it stays in place, retains its selection, and
announces a screen-reader reason — deliberately distinct from #780's
aria-pressed layer toggles.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:48:36 +02:00
Marcel
f3c2465465 feat(timeline): add the LetterBucket cluster component
Renders one loose-letter cluster for Ereignis/Thema mode (#827): an
"✉ <event> · <n>" header over .lcard.ev cards in Ereignis, a tinted
BucketHeaderChip over chip-suppressed cards in Thema, and a localized
"Weitere Briefe"/"Ohne Thema" header with plain cards for the fallback bucket.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:42:45 +02:00
Marcel
fd67a21610 feat(i18n): add grouping + bucket message keys for the Zeitstrahl toggle
New de/en/es keys for #827: the Datum·Ereignis·Thema segment labels and their
≤320px abbreviations, the dynamic meta-line grouping labels
(timeline_grouping_event/_thema), the "Weitere Briefe"/"Ohne Thema" bucket
labels, the radiogroup aria-label, the letters-hidden disabled reason, and the
multi-tag hint. Reuses the existing timeline_grouping_date / timeline_tag_chip_label.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:40:12 +02:00
Marcel
0ae4e9a311 feat(timeline): give LetterCard an event variant and chip suppression
Add a `variant="event"` that marks the card `.lcard.ev` for Ereignis-mode event
clusters (#827, REQ-014) and a `suppressTagChip` that hides the per-letter
TagChip inside its own Thema bucket where the header already conveys the topic
(REQ-017). Datum/Ereignis keep the #838 per-letter chip behaviour.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:37:54 +02:00
Marcel
99528e6bea feat(timeline): add the tinted Thema bucket-header chip
A fully-tinted root-tag chip for Thema-mode bucket headers (#827, REQ-015):
fill and label both derive from the tag's --c-tag-* token via a color-mix wash
so the label keeps ≥4.5:1 contrast in light and dark mode. A null or unknown
token falls back to a neutral chip with no broken colour. Curator text is
{...}-escaped (REQ-009). Distinct from the neutral per-letter TagChip.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:34:29 +02:00
Marcel
4b11d66ca5 feat(timeline): add the client-side letter regroup transform
Pure module powering the #827 Datum·Ereignis·Thema toggle: buildEventLookup
(curated events that survived the #780 layer filter), hasLooseLetters (the
control's enabled state), and bucketLetters (cluster loose letters by
linkedEventId or primary root tag, with a "Weitere Briefe"/"Ohne Thema"
fallback). Filter-then-group, no refetch.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:31:03 +02:00
Marcel
0726226c95 chore(api): regenerate types with TimelineEntryDTO.linkedEventId
Regenerated frontend/src/lib/generated/api.ts from the live OpenAPI spec after
adding the nullable linkedEventId field — keeps the CI type-check green for the
#827 grouping UI that consumes it.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:27:57 +02:00
Marcel
e613a93213 feat(timeline): compute a letter's linkedEventId in the timeline DTO
Add a nullable linkedEventId to TimelineEntryDTO — the curated event whose
documents set contains the letter — resolved in one batched membership pass
over the already-loaded events (no per-letter query, no new column). This is
the single backend field the #827 Ereignis grouping mode consumes.

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:25:00 +02:00
30 changed files with 1763 additions and 48 deletions

View File

@@ -2,7 +2,7 @@
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
> matrix is the part of the spec that *is* committed: it links each requirement to its issue,
> matrix is the part of the spec that _is_ committed: it links each requirement to its issue,
> the code that implements it, and the test(s) that prove it — so any requirement traces end
> to end, and any orphan (a requirement with no test) is visible on `main`.
@@ -24,30 +24,31 @@
## Matrix
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|---|---|---|---|---|---|---|
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
| ------- | ---------------------------------------------------------------------- | -------- | ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (\_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (\_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (\_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (\_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-005 | SPOUSE*OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
@@ -182,7 +183,7 @@
| REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done |
| REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done |
| REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done |
| REQ-010 | 8 timeline_filter_* keys in de/en/es; trigger vs trigger_active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
| REQ-010 | 8 timeline_filter*_ keys in de/en/es; trigger vs trigger*active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
| REQ-001 | WRITE_ALL viewer → "Ereignis hinzufügen" link to /zeitstrahl/events/new in the wrapping Zeitstrahl header | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/messages/{de,en,es}.json` | `zeitstrahl/page.svelte.spec.ts#renders the add-event CTA in a wrapping header when the viewer can write` | Done |
| REQ-002 | viewer without WRITE_ALL → no add-event affordance on /zeitstrahl | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#renders no add-event CTA when the viewer cannot write` | Done |
| REQ-003 | WRITE_ALL viewer → person-page "Ereignis für diese Person" link to /zeitstrahl/events/new?personId={id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/messages/{de,en,es}.json` | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` | Done |
@@ -194,3 +195,24 @@
| 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 | axis-fixed layers (life-events, pills, world-bands) render identically across all 3 modes; only loose letters re-bundle | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `grouping-event-layer-identity.svelte.spec.ts#renders the event pills and world-bands identically across all three grouping modes`, `YearBand.svelte.spec.ts#still renders the event world-band in Ereignis mode` | Done |
| REQ-002 | mode switch re-bundles loose letters over the layer-filtered view, no GET /api/timeline refetch | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/TimelineView.svelte` | `zeitstrahl/page.svelte.spec.ts#regroups loose letters under their event client-side`, `e2e/zeitstrahl-grouping.spec.ts#switching grouping modes issues no extra timeline fetch` | Done |
| REQ-003 | Ereignis clusters each loose letter under the curated event whose documents contain it | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#clusters letters under the curated event named by linkedEventId`, `YearBand.svelte.spec.ts#clusters loose letters under their linked event in Ereignis mode` | Done |
| REQ-004 | Thema buckets each loose letter per year under its primary root tag (rootTagId) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#buckets letters under their primary root tag with name and colour`, `YearBand.svelte.spec.ts#buckets loose letters under their root tag in Thema mode` | Done |
| REQ-005 | TimelineEntryDTO carries nullable linkedEventId, resolved in one batched membership pass | #827 | timeline-grouping-modes | `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` | Done |
| REQ-005b | linkedEventId is nullable / not @Schema REQUIRED; null for non-letter entries | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `frontend/src/lib/generated/api.ts` (`linkedEventId?`) | `TimelineServiceTest#letter_in_no_curated_event_has_null_linkedEventId` | Done |
| REQ-006 | Ereignis: letter with null linkedEventId → per-year "Weitere Briefe" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops a letter with no linkedEventId into the fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Weitere Briefe" label` | Done |
| REQ-007 | Thema: untagged letter → per-year "Ohne Thema" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops an untagged letter into the "Ohne Thema" fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Ohne Thema" label` | Done |
| REQ-008 | multi-tagged letter appears under exactly one root tag, never duplicated | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts` | `timelineGrouping.spec.ts#places a letter in exactly one bucket` | Done |
| REQ-009 | tag names + hint render via `{...}` escaping; grep gate forbids `{@html}` in lib/timeline | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte`, `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/TagChip.svelte` | `BucketHeaderChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive` | Done |
| REQ-010 | grouping control is a keyboard-navigable role=radiogroup, ≥44px text segments, default Datum, dark-mode contrast | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#renders three radios inside a radiogroup`, `#moves the selection forward with the right arrow key`, `#each segment has a tap target of at least 44×44px`, `#defaults to Datum`; `e2e/zeitstrahl-grouping.spec.ts#no wcag2a/wcag2aa violations ... (light + dark)` | Done |
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant, **nested directly under its event pill** with no duplicate title (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards`, `YearBand.svelte.spec.ts#nests an event cluster under its pill in the same year without repeating the title` | Done |
| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done |
| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done |
| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done |
| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done |
| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done |
| REQ-020 | Grouped buckets are bound by a colour rail and carry compact cards; a bucket over `BUCKET_DENSE_THRESHOLD` (6) collapses to the month-density `YearLetterStrip` instead of flooding the timeline (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#isBucketDense`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#collapses an oversized bucket to the density strip instead of flooding cards`, `#binds a tag bucket together with a coloured left rail from its token`, `#renders compact cards for a small bucket`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | 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 (#827). 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,9 +80,14 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// Fetch curated events once — reused for both the event entries below and the
// batched letter→event link resolution (resolveLetterEventLinks), so the
// membership pass costs no extra query. REQ-005.
List<TimelineEvent> allEvents = eventRepository.findAll();
// ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>();
for (TimelineEvent ev : eventRepository.findAll()) {
for (TimelineEvent ev : allEvents) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
@@ -107,8 +112,9 @@ public class TimelineService {
letters.add(doc);
}
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId));
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
}
return bucket(entries);
@@ -229,11 +235,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 +259,38 @@ 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-005). 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 first by repository iteration order wins ({@code putIfAbsent}). The map is built
* from <em>all</em> events (not just the year/type-filtered ones) so the link is a stable
* property of the data; the frontend's filter-then-group decides whether the linked event is
* actually on screen (#827). 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();
Map<UUID, UUID> eventByDocId = new HashMap<>();
for (TimelineEvent ev : events) {
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,44 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList());
}
// ─── letter→event link (#827, REQ-005/006) ───────────────────────────────
@Test
void letter_in_a_curated_events_documents_carries_that_events_id() {
// REQ-005: 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-006: a letter referenced by no curated event → linkedEventId null (frontend falls
// back to the per-year "Weitere Briefe" bucket).
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();
}
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0);
@@ -523,7 +561,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

@@ -0,0 +1,78 @@
# ADR-045 — The /zeitstrahl Ereignis/Thema regroup is client-side, over a computed letter→event link
**Status:** Accepted
**Date:** 2026-06-15
**Issue:** #827 (Zeitstrahl milestone; deferred follow-up to #779, builds on #835/PR #838 and #780)
## Context
#779 shipped `/zeitstrahl` in **Datum** mode only and deferred the Concept-A
**Datum · Ereignis · Thema** segmented control, because the other two modes need data the
`TimelineEntryDTO` did not carry: a letter's curated-event association (Ereignis) and a letter's
primary root tag + colour (Thema). #835 (merged in PR #838) added the Thema fields
(`rootTagId`/`rootTagName`/`rootTagColor`) and the batched `TimelineService → TagService`
resolver. Meanwhile #780 added the **layer filter**`/zeitstrahl/+page.svelte` owns
`personalOn`/`historicalOn`/`lettersOn` `$state` and renders `TimelineView` over a client-side
`filterTimeline(data.timeline, …)` view.
This ADR records the three forks specific to **#827** (the Thema enrichment + the
`TimelineService → TagService` edge are #835's scope, not this one).
## Decisions
### 1. Grouping is a client-side presentation transform — no `grouping=` query param
`GET /api/timeline` already returns the whole timeline in one payload. Regrouping the loose
letters is an in-memory transform in `lib/timeline/timelineGrouping.ts` (`bucketLetters`,
`buildEventLookup`, `hasLooseLetters`), driven by a `groupingMode` `$state` in `+page.svelte`.
A server-side `grouping=DATE|EVENT|TOPIC` parameter was rejected: it would add lasting API
surface and a bucket query for zero benefit on an already-loaded payload, and switching modes
must issue **zero** extra fetches (REQ-002). The blast radius stays inside the read view.
### 2. The letter→event link is computed, reusing `timeline_event_documents` — no new column
A letter clusters under a curated event iff that event's `documents` set (ADR-040;
`@ManyToMany @BatchSize(50)` over join table `timeline_event_documents`) contains the letter's
document. `TimelineService.assemble` resolves this in **one batched membership pass**
`resolveLetterEventLinks` builds a single `docId → eventId` map over the already-loaded events
(no per-letter query), reusing the same `eventRepository.findAll()` it already iterates for the
event entries. The result is exposed as one nullable DTO field, `linkedEventId`. A new persisted
FK on the document/letter row was rejected: it duplicates an existing capability and opens a
mutating write path + Flyway migration for no gain. **No new column, no migration, no new
cross-domain edge** (the field derives from data `TimelineService` already loads). `linkedEventId`
is deliberately **not** `@Schema(requiredMode = REQUIRED)` — it is null for non-letter entries and
for letters under no curated event — so the generated TypeScript type stays optional.
### 3. Grouping composes with the #780 layer filter as **filter-then-group**
The pipeline is `data.timeline → filterTimeline() (#780) → groupingMode transform → TimelineView`.
The grouping `$state` lives in `+page.svelte` beside the filter `$state`, and the regroup runs over
the layer-**filtered** view, never the raw `data.timeline`. Grouping the raw timeline and filtering
afterward was rejected: the counts and buckets would disagree with the layer toggles, re-opening
the #780 count-mismatch the page already closed. Two consequences fall out of filter-then-group:
- **Letters layer off → the grouping control disables, kept in place (REQ-018).** With no loose
letters in the filtered view there is nothing to regroup; the control renders `aria-disabled`
(no header reflow), keeps its selected mode, and announces a screen-reader reason.
- **A letter whose only linking event was filtered out falls back to "Weitere Briefe" (REQ-019).**
`buildEventLookup` is built from the events present in the _filtered_ view, so Ereignis clusters
only under events that survived the filter; everything else lands in the per-year fallback bucket.
The control is a `role="radiogroup"` (single-select), deliberately distinct from #780's
`aria-pressed` toggle filter, stacked above the filter trigger so the two read as one control
cluster — the top-right corner stays the #842 add-event CTA.
## Consequences
- One nullable field (`linkedEventId`) is added to `TimelineEntryDTO` (17 components); the
regenerated `frontend/src/lib/generated/api.ts` is committed in the same PR. No table, column,
Flyway migration, endpoint, `ErrorCode`, or `Permission` changes.
- The regroup is pure and fully unit-tested independently of the components; `TimelineView`/
`YearBand` render the axis-fixed event layer identically across all three modes (REQ-001) and
only swap the loose-letter rendering for per-year `LetterBucket`s off Datum.
- The new Thema bucket-header chip (`BucketHeaderChip`) is a filled variant tinted from
`rootTagColor`; the shipped neutral per-letter `TagChip` (#838) is reused as-is and suppressed
inside its own bucket (REQ-017). All `lib/timeline` components keep the `{...}`-escaping
guarantee — a grep gate forbids `{@html}` (REQ-009).
- Read-only feature: no new authn/authz surface beyond the existing `READ_ALL` on
`GET /api/timeline`.

View File

@@ -50,7 +50,7 @@ src/
│ │ ├── relationship/ # Relationship form + chip components
│ │ └── genealogy/ # Stammbaum (family tree) components
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, LetterBucket, BucketHeaderChip, GroupingControl, TagChip, YearLetterStrip, GapSpan; dateLabel + timelineDensity + timelineFilter + timelineGrouping + eventCardConfig (imports $lib/shared only, never document/)
│ ├── geschichte/ # Geschichte (story) domain: editor + card
│ ├── notification/ # Notification bell + dropdown + store
│ ├── activity/ # Activity feed (Chronik) components

View File

@@ -0,0 +1,123 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect, type APIRequestContext } from '@playwright/test';
/**
* Global /zeitstrahl grouping toggle (#827). Runs against the real stack with the seeded admin
* session (auth.setup). Covers REQ-002 (switching modes issues zero extra GET /api/timeline
* requests — the regroup is client-side), REQ-011 (the control stays usable and overflow-free at
* 320px with full-word aria-labels and ≥44px tap targets), and REQ-010g (a 320px axe pass over
* the control in both light and dark mode).
*
* Per e2e/CLAUDE.md, E2E is not yet wired into CI — this gate runs locally for now, like the
* #780 layer-filter spec it mirrors.
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
const res = await request.post('/api/persons', {
data: { personType: 'PERSON', firstName, lastName }
});
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
return (await res.json()).id as string;
}
/** Seeds one dated letter so the timeline has a loose letter and the grouping control is enabled. */
async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) {
const senderId = await createPerson(request, 'Group-Test', `Absender ${stamp()}`);
const receiverId = await createPerson(request, 'Group-Test', `Empfaenger ${stamp()}`);
const createRes = await request.post('/api/documents', { multipart: { title } });
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
const docId = (await createRes.json()).id as string;
const put = await request.put(`/api/documents/${docId}`, {
multipart: {
title,
documentDate: isoDate,
metaDatePrecision: 'DAY',
senderId,
receiverIds: receiverId
}
});
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
}
test.describe('Zeitstrahl — grouping toggle (#827)', () => {
test('switching grouping modes issues no extra timeline fetch (REQ-002)', async ({
page,
request
}) => {
await seedDatedLetter(request, '1909-05-05', `E2E Group Brief ${stamp()}`);
let timelineRequests = 0;
page.on('request', (req) => {
if (req.url().includes('/api/timeline')) timelineRequests++;
});
await page.goto('/zeitstrahl');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByTestId('grouping-control')).toBeVisible();
const afterLoad = timelineRequests;
await page.locator('[data-value="event"]').click();
await page.locator('[data-value="thema"]').click();
await page.locator('[data-value="date"]').click();
// the regroup is a pure client-side transform — not one more GET /api/timeline
expect(timelineRequests).toBe(afterLoad);
});
test('the control stays overflow-free and operable at 320px (REQ-011)', async ({
page,
request
}) => {
await seedDatedLetter(request, '1911-02-02', `E2E Group 320 ${stamp()}`);
await page.setViewportSize({ width: 320, height: 800 });
await page.goto('/zeitstrahl');
await page.waitForSelector('[data-hydrated]');
const control = page.getByTestId('grouping-control');
await expect(control).toBeVisible();
// the control fits inside the 320px viewport — no horizontal overflow
const box = await control.boundingBox();
expect(box).not.toBeNull();
expect(box!.x + box!.width).toBeLessThanOrEqual(321);
for (const [value, fullWord] of [
['date', 'Datum'],
['event', 'Ereignis'],
['thema', 'Thema']
]) {
const radio = page.locator(`[data-value="${value}"]`);
const radioBox = await radio.boundingBox();
expect(radioBox!.height).toBeGreaterThanOrEqual(44);
expect(radioBox!.width).toBeGreaterThanOrEqual(44);
// the abbreviated segment still announces its full word
expect(await radio.getAttribute('aria-label')).toBe(fullWord);
}
});
test('no wcag2a/wcag2aa violations on the grouping control at 320px (light + dark) (REQ-010g)', async ({
page,
request
}) => {
await seedDatedLetter(request, '1915-06-15', `E2E Group A11y ${stamp()}`);
await page.setViewportSize({ width: 320, height: 800 });
await page.goto('/zeitstrahl');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByTestId('grouping-control')).toBeVisible();
const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
const light = await scan();
expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]);
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const dark = await scan();
expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]);
});
});

View File

@@ -1050,6 +1050,19 @@
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"timeline_grouping_date": "Gruppierung: Datum",
"timeline_grouping_event": "Gruppierung: Ereignis",
"timeline_grouping_thema": "Gruppierung: Thema",
"timeline_grouping_aria_label": "Gruppierung",
"timeline_grouping_segment_date": "Datum",
"timeline_grouping_segment_event": "Ereignis",
"timeline_grouping_segment_thema": "Thema",
"timeline_grouping_segment_date_short": "Dat.",
"timeline_grouping_segment_event_short": "Ereig.",
"timeline_grouping_segment_thema_short": "Thema",
"timeline_grouping_disabled_reason": "Briefe sind ausgeblendet es gibt nichts zu gruppieren.",
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
"timeline_bucket_other_letters": "Weitere Briefe",
"timeline_bucket_no_topic": "Ohne Thema",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_letter_glyph_label": "Brief",

View File

@@ -1050,6 +1050,19 @@
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"timeline_grouping_date": "Grouping: Date",
"timeline_grouping_event": "Grouping: Event",
"timeline_grouping_thema": "Grouping: Topic",
"timeline_grouping_aria_label": "Grouping",
"timeline_grouping_segment_date": "Date",
"timeline_grouping_segment_event": "Event",
"timeline_grouping_segment_thema": "Topic",
"timeline_grouping_segment_date_short": "Date",
"timeline_grouping_segment_event_short": "Event",
"timeline_grouping_segment_thema_short": "Topic",
"timeline_grouping_disabled_reason": "Letters are hidden — there is nothing to group.",
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
"timeline_bucket_other_letters": "More letters",
"timeline_bucket_no_topic": "No topic",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_letter_glyph_label": "Letter",

View File

@@ -1050,6 +1050,19 @@
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"timeline_grouping_date": "Agrupación: Fecha",
"timeline_grouping_event": "Agrupación: Evento",
"timeline_grouping_thema": "Agrupación: Tema",
"timeline_grouping_aria_label": "Agrupación",
"timeline_grouping_segment_date": "Fecha",
"timeline_grouping_segment_event": "Evento",
"timeline_grouping_segment_thema": "Tema",
"timeline_grouping_segment_date_short": "Fecha",
"timeline_grouping_segment_event_short": "Evento",
"timeline_grouping_segment_thema_short": "Tema",
"timeline_grouping_disabled_reason": "Las cartas están ocultas: no hay nada que agrupar.",
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
"timeline_bucket_other_letters": "Más cartas",
"timeline_bucket_no_topic": "Sin tema",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_letter_glyph_label": "Carta",

View File

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

View File

@@ -133,4 +133,33 @@ describe('message key parity', () => {
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
});
// #827 REQ-012: the grouping toggle + bucket strings are new Paraglide keys in
// every locale; the pre-existing timeline_grouping_date / timeline_tag_chip_label /
// timeline_filter_* set is reused, never re-added.
it('zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)', () => {
const requiredKeys = [
'timeline_grouping_event',
'timeline_grouping_thema',
'timeline_grouping_aria_label',
'timeline_grouping_segment_date',
'timeline_grouping_segment_event',
'timeline_grouping_segment_thema',
'timeline_grouping_segment_date_short',
'timeline_grouping_segment_event_short',
'timeline_grouping_segment_thema_short',
'timeline_grouping_disabled_reason',
'timeline_grouping_multitag_hint',
'timeline_bucket_other_letters',
'timeline_bucket_no_topic'
];
for (const key of requiredKeys) {
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
// the pre-existing meta-line + chip keys are reused by #827, not re-declared
expect(de).toHaveProperty('timeline_grouping_date');
expect(de).toHaveProperty('timeline_tag_chip_label');
});
});

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
/**
* The header chip of a Thema-mode root-tag bucket (#827, REQ-015): a *fully-tinted* chip whose
* fill and label both derive from the root tag's `--c-tag-*` colour token — distinct from the
* neutral per-letter {@link TagChip} (a surface pill with a tiny colour square). The label uses
* the saturated token as text over a subtle `color-mix` wash of the same token, so the ≥4.5:1
* label contrast holds in both light and dark themes. A `null` colour — or any value outside the
* known token set (the §2 `krieg`/`weih`/`fam` are demo class names, not tokens) — falls back to a
* neutral chip with no `var(--c-tag-)` reference, never a broken colour. The name is
* curator/import-derived and rendered through default `{...}` escaping, never the raw-HTML
* directive (REQ-009).
*/
const TAG_COLORS = new Set([
'sage',
'sienna',
'amber',
'slate',
'violet',
'rose',
'cobalt',
'moss',
'sand',
'coral'
]);
let { name, color }: { name: string; color: string | null } = $props();
const token = $derived(color && TAG_COLORS.has(color) ? color : null);
// The tint paints the chip's fill + dot only — never the label text. The saturated
// --c-tag-* tokens used AS text over their own wash drop below WCAG AA 4.5:1 for the
// light tokens (amber ≈3.0, sand ≈3.2, sage ≈3.4); a fixed dark ink keeps every token
// legible while the 18% wash still reads as a genuinely tinted chip (REQ-015).
const chipStyle = $derived(
token ? `background-color: color-mix(in srgb, var(--c-tag-${token}) 18%, transparent)` : ''
);
const dotStyle = $derived(token ? `background-color: var(--c-tag-${token})` : '');
</script>
<span
data-testid="bucket-header-chip"
title={name}
style={chipStyle}
class="inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-0.5 font-sans text-xs font-semibold"
class:border={!token}
class:border-line={!token}
class:bg-surface={!token}
>
<span class="sr-only">{m.timeline_tag_chip_label()}: </span>
<span
data-testid="bucket-header-chip-dot"
aria-hidden="true"
style={dotStyle}
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm"
class:bg-ink-3={!token}
></span>
<span
data-testid="bucket-header-chip-label"
style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0"
class:text-ink={token}
class:text-ink-3={!token}>{name}</span
>
</span>

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import BucketHeaderChip from './BucketHeaderChip.svelte';
afterEach(() => cleanup());
describe('BucketHeaderChip (REQ-015/009)', () => {
it('renders the root-tag name', () => {
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
expect(document.body.textContent).toContain('Krieg');
});
it('tints the chip with var(--c-tag-{token}) for a known colour token (REQ-015)', () => {
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
const chip = document.querySelector('[data-testid="bucket-header-chip"]') as HTMLElement;
expect(chip.getAttribute('style')).toContain('var(--c-tag-sienna)');
});
it('renders a neutral chip with no --c-tag- binding when colour is null (REQ-015)', () => {
render(BucketHeaderChip, { name: 'Ohne Thema', color: null });
expect(document.body.textContent).toContain('Ohne Thema');
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
});
it('falls back to neutral for an unknown colour token, never a broken var (REQ-015)', () => {
// "krieg" is a §2 demo class name, not a real --c-tag-* token.
render(BucketHeaderChip, { name: 'Krieg', color: 'krieg' });
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
});
it('prefixes the name with an sr-only theme label so colour is never the only cue', () => {
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toContain(m.timeline_tag_chip_label());
});
it('renders an HTML-bearing name as inert text, never markup (REQ-009)', () => {
const evil = '<img src=x onerror="alert(1)">';
render(BucketHeaderChip, { name: evil, color: null });
expect(document.body.textContent).toContain(evil);
expect(document.querySelector('img')).toBeNull();
});
it('paints the label in a fixed ink colour, never the saturated tag token (contrast, REQ-015)', () => {
// A saturated --c-tag-* token used as TEXT over its own wash fails 4.5:1 for the
// light tokens (amber/sand/sage ≈ 3:1). The tint must go to the background + dot;
// the label keeps a guaranteed-contrast ink token.
render(BucketHeaderChip, { name: 'Weihnachten', color: 'amber' });
const chip = document.querySelector('[data-testid="bucket-header-chip"]') as HTMLElement;
expect(chip.getAttribute('style') ?? '').not.toContain('color: var(--c-tag-');
const label = document.querySelector('[data-testid="bucket-header-chip-label"]') as HTMLElement;
expect(label.className).toContain('text-ink');
// still genuinely tinted — the token paints the wash and the dot
expect(document.body.innerHTML).toContain('var(--c-tag-amber)');
});
});

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { GroupingMode } from './timelineGrouping';
/**
* The Datum·Ereignis·Thema segmented control (#827, REQ-010/011/018). An ARIA radiogroup with
* roving tabindex — single selection, arrow-key navigable — deliberately distinct from #780's
* `aria-pressed` layer-filter toggles. Defaults to Datum. Each segment is ≥44×44px, carries a
* text label (full word as `aria-label`, an abbreviated label shown ≤360px so the control never
* overflows at 320px), and uses semantic tokens so the selected/unselected contrast holds in dark
* mode. When `disabled` (the Letters layer is off, nothing to regroup) the control stays in place
* — no reflow — keeps its `aria-checked` selection so re-enabling restores the mode, and announces
* a screen-reader reason.
*/
let {
mode = $bindable('date'),
disabled = false,
ariaLabel = m.timeline_grouping_aria_label()
}: { mode?: GroupingMode; disabled?: boolean; ariaLabel?: string } = $props();
interface Segment {
value: GroupingMode;
full: string;
short: string;
}
const segments: Segment[] = [
{
value: 'date',
full: m.timeline_grouping_segment_date(),
short: m.timeline_grouping_segment_date_short()
},
{
value: 'event',
full: m.timeline_grouping_segment_event(),
short: m.timeline_grouping_segment_event_short()
},
{
value: 'thema',
full: m.timeline_grouping_segment_thema(),
short: m.timeline_grouping_segment_thema_short()
}
];
function select(value: GroupingMode) {
if (disabled) return;
mode = value;
}
function onKeydown(event: KeyboardEvent) {
if (disabled) return;
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
if (!forward && !backward) return;
event.preventDefault();
const index = segments.findIndex((s) => s.value === mode);
const delta = forward ? 1 : -1;
const next = segments[(index + delta + segments.length) % segments.length];
mode = next.value;
const groupEl = event.currentTarget as HTMLElement;
groupEl.querySelector<HTMLElement>(`[data-value="${next.value}"]`)?.focus();
}
</script>
<div
role="radiogroup"
tabindex="-1"
aria-label={ariaLabel}
aria-disabled={disabled}
data-testid="grouping-control"
class="inline-flex overflow-hidden rounded-md border border-line"
onkeydown={onKeydown}
>
{#each segments as segment (segment.value)}
<button
type="button"
role="radio"
data-value={segment.value}
aria-label={segment.full}
aria-checked={mode === segment.value}
tabindex={mode === segment.value ? 0 : -1}
disabled={disabled}
onclick={() => select(segment.value)}
style="display: inline-flex; align-items: center; justify-content: center; min-height: 44px; min-width: 44px"
class="px-3 py-2 font-sans text-xs font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
class:bg-brand-navy={mode === segment.value && !disabled}
class:text-white={mode === segment.value && !disabled}
class:bg-surface={mode !== segment.value || disabled}
class:text-ink-3={mode !== segment.value || disabled}
>
<span class="seg-full">{segment.full}</span>
<span class="seg-short">{segment.short}</span>
</button>
{/each}
</div>
{#if disabled}
<span class="sr-only" role="status" data-testid="grouping-disabled-reason"
>{m.timeline_grouping_disabled_reason()}</span
>
{/if}
<style>
.seg-short {
display: none;
}
@media (max-width: 360px) {
.seg-full {
display: none;
}
.seg-short {
display: inline;
}
}
</style>

View File

@@ -0,0 +1,106 @@
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 GroupingControl from './GroupingControl.svelte';
afterEach(() => cleanup());
const radios = () => Array.from(document.querySelectorAll('[role="radio"]')) as HTMLElement[];
const group = () => document.querySelector('[role="radiogroup"]') as HTMLElement;
const checkedValue = () =>
radios()
.find((r) => r.getAttribute('aria-checked') === 'true')
?.getAttribute('data-value');
describe('GroupingControl (REQ-010)', () => {
it('renders three radios inside a radiogroup, each with aria-checked (a)', () => {
render(GroupingControl, {});
expect(group()).not.toBeNull();
const r = radios();
expect(r).toHaveLength(3);
r.forEach((radio) => expect(radio.hasAttribute('aria-checked')).toBe(true));
});
it('defaults to Datum (f)', () => {
render(GroupingControl, {});
expect(radios().filter((r) => r.getAttribute('aria-checked') === 'true')).toHaveLength(1);
expect(checkedValue()).toBe('date');
});
it('exposes a text label on every segment, not colour alone (d)', () => {
render(GroupingControl, {});
radios().forEach((r) => expect((r.textContent ?? '').trim().length).toBeGreaterThan(0));
});
it('gives the radiogroup an accessible name (e)', () => {
render(GroupingControl, {});
expect(group().getAttribute('aria-label')).toBe(m.timeline_grouping_aria_label());
});
it('each segment has a tap target of at least 44×44px (c)', () => {
render(GroupingControl, {});
radios().forEach((r) => {
const rect = r.getBoundingClientRect();
expect(rect.width).toBeGreaterThanOrEqual(44);
expect(rect.height).toBeGreaterThanOrEqual(44);
});
});
it('exposes each segment full word as an aria-label (REQ-011)', () => {
render(GroupingControl, {});
const labels = radios().map((r) => r.getAttribute('aria-label'));
expect(labels).toEqual([
m.timeline_grouping_segment_date(),
m.timeline_grouping_segment_event(),
m.timeline_grouping_segment_thema()
]);
});
it('moves the selection forward with the right arrow key (b)', async () => {
render(GroupingControl, { mode: 'date' });
group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await tick();
expect(checkedValue()).toBe('event');
});
it('wraps to the last segment with the left arrow from Datum (b)', async () => {
render(GroupingControl, { mode: 'date' });
group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await tick();
expect(checkedValue()).toBe('thema');
});
it('selects a segment on click', async () => {
render(GroupingControl, { mode: 'date' });
const thema = radios().find((r) => r.getAttribute('data-value') === 'thema')!;
thema.click();
await tick();
expect(thema.getAttribute('aria-checked')).toBe('true');
});
});
describe('GroupingControl — disabled (REQ-018)', () => {
it('marks the radiogroup aria-disabled and keeps all radios in the DOM', () => {
render(GroupingControl, { mode: 'event', disabled: true });
expect(group().getAttribute('aria-disabled')).toBe('true');
expect(radios()).toHaveLength(3);
});
it('announces a screen-reader reason that letters are hidden', () => {
render(GroupingControl, { disabled: true });
const reason = document.querySelector('[data-testid="grouping-disabled-reason"]');
expect(reason?.textContent).toContain(m.timeline_grouping_disabled_reason());
});
it('retains the active mode while disabled (no reset to Datum)', () => {
render(GroupingControl, { mode: 'thema', disabled: true });
expect(checkedValue()).toBe('thema');
});
it('ignores arrow keys while disabled', () => {
render(GroupingControl, { mode: 'event', disabled: true });
group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(checkedValue()).toBe('event');
});
});

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import BucketHeaderChip from './BucketHeaderChip.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import { entryKey } from './entryKey';
import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping';
/**
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
* unit (#827). The axis-fixed event/world-band layers are rendered elsewhere — this is only the
* loose-letter bundling.
*
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
* tag chip is suppressed (REQ-004/015/017).
* - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the
* header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" /
* "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007).
*
* A bucket larger than the density threshold collapses to the month-density `YearLetterStrip`
* instead of flooding the timeline with every card (#827) — the catch-all buckets are the biggest.
*/
let {
bucket,
mode,
year = 0,
nested = false
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
const count = $derived(bucket.letters.length);
const dense = $derived(isBucketDense(count));
const fallbackLabel = $derived(
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
);
// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster,
// neutral for the fallback (and for a colourless/unknown tag token).
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
const isEventCluster = $derived(nested || bucket.kind === 'event');
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
</script>
<section
class="my-3 border-l-2 pl-3"
class:border-l-brand-mint={isEventCluster}
class:border-line={!railColor && !isEventCluster}
style={railStyle}
data-testid="letter-bucket"
data-bucket-kind={bucket.kind}
>
{#if !nested}
<header class="mb-2 flex items-center gap-2">
{#if mode === 'thema' && bucket.kind === 'tag'}
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
{:else if mode === 'event' && bucket.kind === 'event'}
<span class="font-serif text-sm font-bold text-ink">
<span aria-hidden="true"></span>
{bucket.title}
</span>
{:else}
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
{/if}
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
</header>
{/if}
{#if dense}
<!-- Oversized bucket → the month-density strip (count + sparkline + expand), not a flood. -->
<YearLetterStrip letters={bucket.letters} year={year} />
{:else}
<ul class="space-y-1.5">
{#each bucket.letters as letter (entryKey(letter))}
<li>
<LetterCard
entry={letter}
variant={cardVariant}
suppressTagChip={mode === 'thema'}
compact={true}
/>
</li>
{/each}
</ul>
{/if}
</section>

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import LetterBucket from './LetterBucket.svelte';
import { makeEntry } from './test-factories';
import type { LetterBucket as Bucket } from './timelineGrouping';
afterEach(() => cleanup());
const eventBucket: Bucket = {
key: 'event:e1',
kind: 'event',
title: 'Briefe von der Front',
color: null,
letters: [makeEntry({ documentId: 'a' }), makeEntry({ documentId: 'b' })]
};
const tagBucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Krieg',
color: 'sienna',
letters: [makeEntry({ documentId: 'c', rootTagName: 'Krieg', rootTagColor: 'sienna' })]
};
describe('LetterBucket — Ereignis mode (REQ-003/006/014)', () => {
it('shows the event title and the cluster count', () => {
render(LetterBucket, { bucket: eventBucket, mode: 'event' });
expect(document.body.textContent).toContain('Briefe von der Front');
expect(document.querySelector('[data-testid="bucket-count"]')?.textContent).toContain('2');
});
it('renders its letters as .lcard.ev event cards (REQ-014)', () => {
render(LetterBucket, { bucket: eventBucket, mode: 'event' });
expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(2);
});
it('uses the localized "Weitere Briefe" label and plain cards for the fallback bucket (REQ-006)', () => {
const fb: Bucket = {
key: '__fallback__',
kind: 'fallback',
color: null,
letters: [makeEntry({ documentId: 'x' })]
};
render(LetterBucket, { bucket: fb, mode: 'event' });
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
// fallback letters are not clustered under a curated event → plain card, never .lcard.ev
expect(document.querySelector('a.ev')).toBeNull();
});
});
describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => {
it('renders a tinted bucket-header chip carrying the root-tag name (REQ-015)', () => {
render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
const chip = document.querySelector('[data-testid="bucket-header-chip"]');
expect(chip?.textContent).toContain('Krieg');
});
it('suppresses the per-letter tag chip inside its own root-tag bucket (REQ-017)', () => {
render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
});
it('uses the localized "Ohne Thema" label for the untagged fallback bucket (REQ-007)', () => {
const fb: Bucket = {
key: '__fallback__',
kind: 'fallback',
color: null,
letters: [makeEntry({ documentId: 'y', rootTagName: undefined })]
};
render(LetterBucket, { bucket: fb, mode: 'thema' });
expect(document.body.textContent).toContain(m.timeline_bucket_no_topic());
});
});
const manyLetters = (n: number) =>
Array.from({ length: n }, (_, i) =>
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
);
describe('LetterBucket — density + containment (#827)', () => {
it('collapses an oversized bucket to the density strip instead of flooding cards', () => {
const bucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Sonstiges',
color: null,
letters: manyLetters(10)
};
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
// not ten individual cards dumped into the timeline
expect(document.querySelectorAll('a.lcard')).toHaveLength(0);
});
it('renders compact cards for a small bucket (no strip)', () => {
const bucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Tod',
color: null,
letters: manyLetters(2)
};
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2);
});
it('omits the header when nested — the event pill above is the header', () => {
const bucket: Bucket = {
key: 'event:e1',
kind: 'event',
title: 'Ein gewaltiger Stadtbrand',
color: null,
letters: manyLetters(1)
};
render(LetterBucket, { bucket, mode: 'event', nested: true, year: 1916 });
expect(document.querySelector('[data-testid="bucket-count"]')).toBeNull();
expect(document.body.textContent).not.toContain('Ein gewaltiger Stadtbrand');
// still the event-letter variant, just headerless under its pill
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
});
it('binds a tag bucket together with a coloured left rail from its token', () => {
const bucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Krieg',
color: 'sienna',
letters: manyLetters(1)
};
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
const section = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
});
});

View File

@@ -12,10 +12,30 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* 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.
*
* In Ereignis mode the card sits inside an event cluster and renders as the
* `.lcard.ev` variant (#827, REQ-014). In Thema mode the per-letter tag chip is
* suppressed inside its own root-tag bucket, where the bucket header already
* carries the topic (`suppressTagChip`, REQ-017).
*/
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 a per-year bucket the year frames the time, and these archive titles already
// embed the date — so the compact in-bucket card drops the redundant date chip when a
// title is present, halving the row height and killing the duplicate date (#827).
const showDate = $derived(!compact || !entry.title);
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
@@ -28,28 +48,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-005), and suppressed in
Thema mode inside its own root-tag bucket where the header conveys it (REQ-017). -->
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
{/if}
</a>

View File

@@ -127,3 +127,46 @@ describe('LetterCard', () => {
expect(chip?.textContent).toContain('Familie');
});
});
describe('LetterCard — grouping variants (#827, REQ-014/017)', () => {
it('carries the .lcard.ev class in the event variant (REQ-014)', () => {
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-014)', () => {
render(LetterCard, { entry: makeEntry() });
expect(document.querySelector('a.ev')).toBeNull();
});
it('suppresses the per-letter tag chip when asked, even with a root tag (REQ-017)', () => {
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 — Datum/Ereignis (REQ-017)', () => {
render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) });
expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull();
});
it('drops the redundant date line in the compact variant when a title is present (#827)', () => {
// Inside a per-year bucket the year already frames the time, and these archive
// titles embed the date — so the compact in-bucket card omits the date chip.
render(LetterCard, { entry: makeEntry({ title: 'H-0023 6. Juli 1916' }), compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).toBeNull();
expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown
});
it('keeps the date in the compact variant when the letter has no title (#827)', () => {
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 (#827)', () => {
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, type GroupingMode } from './timelineGrouping';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
@@ -18,12 +19,28 @@ 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.
*
* `groupingMode` (#827) flows down to each YearBand to re-bundle its loose letters;
* the event lookup — the curated events present in this (already layer-filtered)
* view — is resolved once here so Ereignis clusters never reference a filtered-out
* event (filter-then-group, REQ-019). The undated bucket renders unchanged in every
* mode (its letters have no year, so the per-year bucketing does not apply).
*/
let {
timeline,
personId = undefined,
canWrite = false
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
canWrite = false,
groupingMode = 'date'
}: {
timeline: TimelineDTO;
personId?: string;
canWrite?: boolean;
groupingMode?: GroupingMode;
} = $props();
const eventLookup = $derived(
groupingMode === 'date' ? new Map<string, string>() : buildEventLookup(timeline)
);
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
@@ -54,7 +71,12 @@ 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}
groupingMode={groupingMode}
eventLookup={eventLookup}
/>
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}

View File

@@ -3,8 +3,14 @@ import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import LetterBucket from './LetterBucket.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import {
bucketLetters,
type GroupingMode,
type LetterBucket as LetterBucketModel
} from './timelineGrouping';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
@@ -15,19 +21,75 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* 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).
*
* In Ereignis/Thema mode (#827) the event pills/world-bands render identically
* (REQ-001); only the loose letters re-bundle into per-year buckets below them
* (REQ-002/003/004). Datum mode is the original individual-card / density-strip
* path, untouched.
*/
let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props();
let {
year,
canWrite = false,
groupingMode = 'date',
eventLookup = new Map<string, string>()
}: {
year: TimelineYearDTO;
canWrite?: boolean;
groupingMode?: GroupingMode;
eventLookup?: Map<string, string>;
} = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' };
| { t: 'strip' }
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
const dense = $derived(isDense(letters.length));
const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
// Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly
// beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose
// pill lives in another year band (or was filtered out) keeps its own header here, and the
// unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019).
if (groupingMode === 'event') {
const buckets = bucketLetters(letters, 'event', eventLookup);
const hasPill = (bucketKey: string) =>
year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey);
// Each pill renders, then its same-year cluster nests directly beneath it (no header).
for (const entry of year.entries) {
if (entry.kind !== 'EVENT') continue;
out.push({ t: 'event', entry });
const bucket = entry.eventId
? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`)
: undefined;
if (bucket) out.push({ t: 'bucket', bucket, nested: true });
}
// Clusters whose pill is in another band keep their header; then the fallback, last.
for (const bucket of buckets) {
if (bucket.kind === 'fallback' || !hasPill(bucket.key)) {
out.push({ t: 'bucket', bucket, nested: false });
}
}
return out;
}
// Thema: events stay on the axis (REQ-001); loose letters re-bundle into per-year root-tag
// buckets below them (REQ-004) — no axis pill exists for a tag, so every bucket keeps a header.
if (groupingMode === 'thema') {
for (const entry of year.entries) {
if (entry.kind === 'EVENT') out.push({ t: 'event', entry });
}
for (const bucket of bucketLetters(letters, 'thema', eventLookup)) {
out.push({ t: 'bucket', bucket, nested: false });
}
return out;
}
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) {
@@ -43,6 +105,12 @@ const rows = $derived.by<Row[]>(() => {
}
return out;
});
function rowKey(row: Row): string {
if (row.t === 'strip') return `strip-${year.year}`;
if (row.t === 'bucket') return row.bucket.key;
return entryKey(row.entry);
}
</script>
<section class="py-2">
@@ -56,7 +124,7 @@ 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} />
@@ -68,6 +136,8 @@ const rows = $derived.by<Row[]>(() => {
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
<LetterCard entry={row.entry} />
</div>
{:else if row.t === 'bucket'}
<LetterBucket bucket={row.bucket} mode={bucketMode} year={year.year} nested={row.nested} />
{:else}
<YearLetterStrip letters={letters} year={year.year} />
{/if}

View File

@@ -165,3 +165,99 @@ describe('YearBand', () => {
}
});
});
describe('YearBand — grouping modes (#827)', () => {
it('keeps individual letter cards and no buckets in Datum mode (default)', () => {
render(YearBand, { year: makeYear(1915, manyLetters(1915, 3)) });
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
expect(document.querySelectorAll('a')).toHaveLength(3);
});
it('clusters loose letters under their linked event in Ereignis mode (REQ-002/003)', () => {
const a = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1915-03-01' });
const b = makeEntry({ documentId: 'b', linkedEventId: 'e1', eventDate: '1915-04-01' });
render(YearBand, {
year: makeYear(1915, [a, b]),
groupingMode: 'event',
eventLookup: new Map([['e1', 'Briefe von der Front']])
});
expect(document.querySelectorAll('[data-testid="letter-bucket"]')).toHaveLength(1);
expect(document.body.textContent).toContain('Briefe von der Front');
// no alternating individual letter rows in grouped mode
expect(document.querySelector('.letter-row')).toBeNull();
});
it('still renders the event world-band in Ereignis mode (REQ-001)', () => {
const band = makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
documentId: undefined
});
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1914-05-01' });
render(YearBand, {
year: makeYear(1914, [band, letter]),
groupingMode: 'event',
eventLookup: new Map([['e1', 'Front']])
});
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
});
it('buckets loose letters under their root tag in Thema mode (REQ-004)', () => {
const a = makeEntry({
documentId: 'a',
rootTagId: 't1',
rootTagName: 'Krieg',
rootTagColor: 'sienna',
eventDate: '1915-03-01'
});
render(YearBand, { year: makeYear(1915, [a]), groupingMode: 'thema', eventLookup: new Map() });
const chip = document.querySelector('[data-testid="bucket-header-chip"]');
expect(chip?.textContent).toContain('Krieg');
});
it('nests an event cluster under its pill in the same year without repeating the title (#827)', () => {
const pill = makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: 'e1',
title: 'Ein gewaltiger Stadtbrand',
eventDate: '1916-07-06',
senderName: '',
receiverName: '',
documentId: undefined
});
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1916-07-06' });
render(YearBand, {
year: makeYear(1916, [pill, letter]),
groupingMode: 'event',
eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']])
});
// the title appears exactly once — on the axis pill, NOT also as a bucket header
const occurrences =
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
expect(occurrences).toBe(1);
// the letter is still clustered (nested under the pill) as the event-letter card
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
});
it('keeps a header on an event cluster whose pill is in another year (#827)', () => {
// the letter links to e1, but e1's pill lives in a different band — so the cluster
// keeps its own header here (no pill nearby to duplicate).
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1917-02-01' });
render(YearBand, {
year: makeYear(1917, [letter]),
groupingMode: 'event',
eventLookup: new Map([['e1', 'Briefe von der Front']])
});
expect(document.body.textContent).toContain('Briefe von der Front');
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TimelineView from './TimelineView.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
import type { GroupingMode } from './timelineGrouping';
afterEach(() => cleanup());
const worldBand = (title: string) =>
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
eventId: 'h1',
title,
senderName: '',
receiverName: '',
documentId: undefined
});
const eventPill = (title: string) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: 'p1',
title,
senderName: '',
receiverName: '',
documentId: undefined
});
// A signature of the axis-fixed event layer: the curated/world-band titles, the world-range
// marker count, and the event-pill count — everything REQ-001 requires to stay constant when
// only the loose letters re-bundle. (No pixel-diff harness in the repo; this is the structural
// equivalent — the event-layer DOM is byte-for-byte built from the same entries in every mode.)
function eventLayerSignature(): string {
const body = document.body.textContent ?? '';
return JSON.stringify({
weltkrieg: body.includes('Erster Weltkrieg'),
hochzeit: body.includes('Hochzeit'),
worldRange: document.querySelectorAll('[data-testid="world-range"]').length
});
}
const mixed = () =>
makeTimelineDTO({
years: [
makeYear(1915, [
worldBand('Erster Weltkrieg'),
eventPill('Hochzeit'),
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }),
makeEntry({
documentId: 'b',
title: 'Brief B',
rootTagId: 't1',
rootTagName: 'Krieg',
rootTagColor: 'sienna'
})
])
]
});
function signatureFor(mode: GroupingMode): string {
render(TimelineView, { timeline: mixed(), groupingMode: mode });
const sig = eventLayerSignature();
cleanup();
return sig;
}
describe('TimelineView event layer (REQ-001)', () => {
it('renders the event pills and world-bands identically across all three grouping modes', () => {
const dateSig = signatureFor('date');
const eventSig = signatureFor('event');
const themaSig = signatureFor('thema');
expect(eventSig).toBe(dateSig);
expect(themaSig).toBe(dateSig);
// sanity: the world-band actually rendered, so the assertion is not vacuously equal on ""
expect(dateSig).toContain('"worldRange":1');
});
it('regroups only the loose letters — buckets appear off Datum, not in it', () => {
render(TimelineView, { timeline: mixed(), groupingMode: 'date' });
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
cleanup();
render(TimelineView, { timeline: mixed(), groupingMode: 'event' });
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
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-009 / CWE-79: the regroup touches every component under lib/timeline (the reused TagChip,
* the .lcard.ev card, and the new tinted bucket-header chip). Curator/import-derived 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-009)', () => {
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

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { buildEventLookup, bucketLetters, hasLooseLetters } from './timelineGrouping';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
// Entry factories pinned to the shapes the grouping transform discriminates (#827).
const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides });
const curatedEvent = (id: string, title: string, overrides = {}) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
documentId: undefined,
eventId: id,
title,
senderName: '',
receiverName: '',
...overrides
});
describe('buildEventLookup (REQ-019)', () => {
it('collects curated events (eventId set) from year bands and the undated bucket', () => {
const dto = makeTimelineDTO({
years: [makeYear(1915, [curatedEvent('e1', 'Briefe von der Front'), letter()])],
undated: [curatedEvent('e2', 'Unbekanntes Ereignis')]
});
const lookup = buildEventLookup(dto);
expect(lookup.get('e1')).toBe('Briefe von der Front');
expect(lookup.get('e2')).toBe('Unbekanntes Ereignis');
expect(lookup.size).toBe(2);
});
it('ignores letters and derived life-events (no eventId)', () => {
const dto = makeTimelineDTO({
years: [
makeYear(1915, [
letter({ linkedEventId: 'e1' }),
makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: true, eventId: undefined })
])
]
});
expect(buildEventLookup(dto).size).toBe(0);
});
});
describe('hasLooseLetters (REQ-018)', () => {
it('is true when a year band or the undated bucket holds a letter', () => {
expect(hasLooseLetters(makeTimelineDTO({ years: [makeYear(1915, [letter()])] }))).toBe(true);
expect(hasLooseLetters(makeTimelineDTO({ undated: [letter({ documentId: 'u1' })] }))).toBe(
true
);
});
it('is false when only events remain', () => {
const dto = makeTimelineDTO({ years: [makeYear(1915, [curatedEvent('e1', 'Ereignis')])] });
expect(hasLooseLetters(dto)).toBe(false);
});
});
describe('bucketLetters — Ereignis mode (REQ-003/006/019)', () => {
const lookup = new Map([
['e1', 'Briefe von der Front'],
['e2', 'Weihnachten 1915']
]);
it('clusters letters under the curated event named by linkedEventId, with matching counts', () => {
const letters = [
letter({ documentId: 'a', linkedEventId: 'e1' }),
letter({ documentId: 'b', linkedEventId: 'e1' }),
letter({ documentId: 'c', linkedEventId: 'e2' })
];
const buckets = bucketLetters(letters, 'event', lookup);
const front = buckets.find((b) => b.title === 'Briefe von der Front');
expect(front?.kind).toBe('event');
expect(front?.letters).toHaveLength(2);
expect(buckets.find((b) => b.title === 'Weihnachten 1915')?.letters).toHaveLength(1);
});
it('drops a letter with no linkedEventId into the fallback bucket (REQ-006)', () => {
const letters = [letter({ documentId: 'a', linkedEventId: undefined })];
const buckets = bucketLetters(letters, 'event', lookup);
expect(buckets).toHaveLength(1);
expect(buckets[0].kind).toBe('fallback');
expect(buckets[0].letters).toHaveLength(1);
});
it('drops a letter whose linked event is absent from the lookup into fallback (REQ-019)', () => {
// e9 is not in the filtered view (its layer was toggled off) → no cluster.
const letters = [letter({ documentId: 'a', linkedEventId: 'e9' })];
const buckets = bucketLetters(letters, 'event', lookup);
expect(buckets).toHaveLength(1);
expect(buckets[0].kind).toBe('fallback');
});
it('keeps the fallback bucket last', () => {
const letters = [
letter({ documentId: 'a', linkedEventId: undefined }),
letter({ documentId: 'b', linkedEventId: 'e1' })
];
const buckets = bucketLetters(letters, 'event', lookup);
expect(buckets[buckets.length - 1].kind).toBe('fallback');
});
});
describe('bucketLetters — Thema mode (REQ-004/007/008)', () => {
const noEvents = new Map<string, string>();
it('buckets letters under their primary root tag with name and colour', () => {
const letters = [
letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }),
letter({ documentId: 'b', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }),
letter({
documentId: 'c',
rootTagId: 't2',
rootTagName: 'Weihnachten',
rootTagColor: 'amber'
})
];
const buckets = bucketLetters(letters, 'thema', noEvents);
const krieg = buckets.find((b) => b.title === 'Krieg');
expect(krieg?.kind).toBe('tag');
expect(krieg?.color).toBe('sienna');
expect(krieg?.letters).toHaveLength(2);
expect(buckets.find((b) => b.title === 'Weihnachten')?.color).toBe('amber');
});
it('drops an untagged letter into the "Ohne Thema" fallback bucket (REQ-007)', () => {
const letters = [letter({ documentId: 'a', rootTagId: undefined })];
const buckets = bucketLetters(letters, 'thema', noEvents);
expect(buckets).toHaveLength(1);
expect(buckets[0].kind).toBe('fallback');
expect(buckets[0].color).toBeNull();
});
it('places a letter in exactly one bucket (REQ-008)', () => {
const letters = [
letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' })
];
const buckets = bucketLetters(letters, 'thema', noEvents);
const occurrences = buckets.flatMap((b) => b.letters).filter((l) => l.documentId === 'a');
expect(occurrences).toHaveLength(1);
});
it('carries a null colour through for a colourless root tag', () => {
const letters = [
letter({
documentId: 'a',
rootTagId: 't3',
rootTagName: 'Allgemein',
rootTagColor: undefined
})
];
const buckets = bucketLetters(letters, 'thema', noEvents);
expect(buckets[0].kind).toBe('tag');
expect(buckets[0].color).toBeNull();
});
});

View File

@@ -0,0 +1,161 @@
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* The three ways a reader can bundle the loose letters on `/zeitstrahl` (#827). The
* axis-fixed layers (life-events, event pills, world-bands) are identical in every mode
* — only loose-letter bundling changes. Grouping runs over the *already layer-filtered*
* timeline (#780): filter-then-group.
*/
export type GroupingMode = 'date' | 'event' | 'thema';
/** The default mode — chronological, as #779 shipped. */
export const DEFAULT_GROUPING: GroupingMode = 'date';
/**
* A bucket larger than this collapses to a month-density strip instead of flooding the
* timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema"
* buckets are always the biggest, so without this they swamp the grouped view. Lower than
* Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year.
*/
export const BUCKET_DENSE_THRESHOLD = 6;
export function isBucketDense(letterCount: number): boolean {
return letterCount > BUCKET_DENSE_THRESHOLD;
}
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
const TAG_COLOR_TOKENS = new Set([
'sage',
'sienna',
'amber',
'slate',
'violet',
'rose',
'cobalt',
'moss',
'sand',
'coral'
]);
/**
* Maps a root-tag colour-name token to its CSS variable reference, or `null` for an absent
* or unknown token (so a colourless/unrecognised tag falls back to a neutral rail, never a
* broken `var(--c-tag-undefined)`).
*/
export function tagColorVar(token: string | null | undefined): string | null {
return token && TAG_COLOR_TOKENS.has(token) ? `var(--c-tag-${token})` : null;
}
/**
* One bundle of loose letters under a single header, within a year (Ereignis/Thema modes).
* `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized
* fallback ("Weitere Briefe" / "Ohne Thema") the view supplies for `kind === 'fallback'`.
*/
export interface LetterBucket {
/** Stable `{#each}` key, unique within a year's bucket list. */
key: string;
kind: 'event' | 'tag' | 'fallback';
/** Header label for `event`/`tag` buckets; absent for `fallback` (view supplies a localized label). */
title?: string;
/** Root-tag colour token for a `tag` bucket; `null` for `event`/`fallback` (neutral). */
color: string | null;
letters: TimelineEntryDTO[];
}
/**
* Maps each curated event present in the (already-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 layer filter removed, so it falls back to "Weitere Briefe" (filter-then-group,
* REQ-019). Curated events carry an `eventId`; derived life-events and letters do not.
*/
export function buildEventLookup(timeline: TimelineDTO): Map<string, string> {
const lookup = new Map<string, string>();
const collect = (entries: TimelineEntryDTO[]) => {
for (const entry of entries) {
if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? '');
}
};
for (const band of timeline.years) collect(band.entries);
collect(timeline.undated);
return lookup;
}
/**
* True when the timeline still holds at least one loose letter. Drives the grouping control's
* enabled state: with the Letters layer filtered off there is nothing to regroup (REQ-018).
*/
export function hasLooseLetters(timeline: TimelineDTO): boolean {
const holdsLetter = (entries: TimelineEntryDTO[]) => entries.some((e) => e.kind === 'LETTER');
return timeline.years.some((band) => holdsLetter(band.entries)) || holdsLetter(timeline.undated);
}
/**
* Buckets one year's loose letters for Ereignis/Thema mode. The caller passes only that year's
* `LETTER` entries; events stay on the axis untouched (REQ-001). Buckets keep first-seen order and
* the fallback bucket, if any, always sorts last.
*
* - `event`: cluster under `linkedEventId` when it is set AND survives in `eventLookup`; otherwise
* the fallback "Weitere Briefe" bucket (REQ-003/006/019).
* - `thema`: bucket under `rootTagId` (header = `rootTagName`, tint = `rootTagColor`); an untagged
* letter goes to the fallback "Ohne Thema" bucket (REQ-004/007). A letter carries exactly one
* `rootTagId`, so it lands in exactly one bucket (REQ-008).
*/
export function bucketLetters(
letters: TimelineEntryDTO[],
mode: Exclude<GroupingMode, 'date'>,
eventLookup: Map<string, string>
): LetterBucket[] {
const byKey = new Map<string, LetterBucket>();
let fallback: LetterBucket | null = null;
const fallbackBucket = (): LetterBucket => {
if (!fallback) fallback = { key: '__fallback__', kind: 'fallback', color: null, letters: [] };
return fallback;
};
const namedBucket = (id: string, build: () => LetterBucket): LetterBucket => {
let bucket = byKey.get(id);
if (!bucket) {
bucket = build();
byKey.set(id, bucket);
}
return bucket;
};
for (const letter of letters) {
if (mode === 'event') {
const id = letter.linkedEventId;
if (id && eventLookup.has(id)) {
namedBucket(id, () => ({
key: `event:${id}`,
kind: 'event',
title: eventLookup.get(id),
color: null,
letters: []
})).letters.push(letter);
} else {
fallbackBucket().letters.push(letter);
}
} else {
const id = letter.rootTagId;
if (id) {
namedBucket(id, () => ({
key: `tag:${id}`,
kind: 'tag',
title: letter.rootTagName ?? '',
color: letter.rootTagColor ?? null,
letters: []
})).letters.push(letter);
} else {
fallbackBucket().letters.push(letter);
}
}
}
const buckets = [...byKey.values()];
if (fallback) buckets.push(fallback);
return buckets;
}

View File

@@ -2,8 +2,10 @@
import * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte';
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
import GroupingControl from '$lib/timeline/GroupingControl.svelte';
import { timelineMeta } from '$lib/timeline/timelineMeta';
import { filterTimeline } from '$lib/timeline/timelineFilter';
import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -17,12 +19,20 @@ let personalOn = $state(true);
let historicalOn = $state(true);
let lettersOn = $state(true);
// Grouping state (#827) lives here beside the layer-filter state; the regroup is a
// pure client-side transform over the already-filtered view — filter-then-group.
let groupingMode = $state<GroupingMode>('date');
const filteredTimeline = $derived(
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
);
const filteredEmpty = $derived(
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
);
// The grouping control is only meaningful while loose letters remain in the filtered
// view; with the Letters layer off there is nothing to regroup, so it disables but
// keeps its selected mode (REQ-018).
const hasLetters = $derived(hasLooseLetters(filteredTimeline));
// Meta-line figures track the *filtered* view, so the header counts always
// match what is actually on screen once layers are toggled off (#780 — this
@@ -60,7 +70,13 @@ const metaLine = $derived.by(() => {
: m.timeline_events_count({ count: meta.eventCount })
);
}
segments.push(m.timeline_grouping_date());
segments.push(
groupingMode === 'event'
? m.timeline_grouping_event()
: groupingMode === 'thema'
? m.timeline_grouping_thema()
: m.timeline_grouping_date()
);
return segments.join(' · ');
});
</script>
@@ -89,7 +105,14 @@ const metaLine = $derived.by(() => {
{/if}
</header>
{#if hasContent}
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
<p data-testid="timeline-meta" class="mt-1 mb-3 font-sans text-xs text-ink-3">{metaLine}</p>
<!-- Grouping toggle stacked above the #780 layer-filter trigger so the two read as
one control cluster in the header (REQ-010); the top-right corner stays the
add-event CTA. Disabled — but kept in place — when no loose letters remain
(REQ-018). -->
<div class="mb-3" data-testid="grouping-cluster">
<GroupingControl bind:mode={groupingMode} disabled={!hasLetters} />
</div>
<TimelineFilters
bind:personalOn={personalOn}
bind:historicalOn={historicalOn}
@@ -112,7 +135,11 @@ const metaLine = $derived.by(() => {
</button>
</div>
{:else}
<TimelineView timeline={filteredTimeline} canWrite={data.canWrite} />
<TimelineView
timeline={filteredTimeline}
canWrite={data.canWrite}
groupingMode={groupingMode}
/>
{/if}
</div>
</div>

View File

@@ -265,3 +265,61 @@ describe('/zeitstrahl curator affordances (#842)', () => {
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
});
describe('/zeitstrahl grouping toggle (#827)', () => {
const historical = () =>
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
eventId: 'h1',
documentId: undefined,
title: 'Erster Weltkrieg',
senderName: '',
receiverName: ''
});
const mixed = () =>
makeTimelineDTO({
years: [
makeYear(1915, [
makeEntry({ documentId: 'd1', title: 'Brief Eins', linkedEventId: 'h1' }),
historical()
])
]
});
const radio = (value: string) => document.querySelector(`[data-value="${value}"]`) as HTMLElement;
it('updates the meta-line grouping label when a mode is chosen (REQ-016)', async () => {
render(Page, { data: pageData(mixed()) });
const meta = page.getByTestId('timeline-meta');
await expect.element(meta).toHaveTextContent(m.timeline_grouping_date());
radio('event').click();
await expect.element(meta).toHaveTextContent(m.timeline_grouping_event());
radio('thema').click();
await expect.element(meta).toHaveTextContent(m.timeline_grouping_thema());
});
it('regroups loose letters under their event client-side, no buckets in Datum (REQ-002/003)', async () => {
render(Page, { data: pageData(mixed()) });
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
radio('event').click();
await expect.element(page.getByTestId('letter-bucket')).toBeVisible();
});
it('disables the grouping control when the Letters layer is off, keeping the mode (REQ-018)', async () => {
render(Page, { data: pageData(mixed()) });
radio('thema').click();
const control = page.getByTestId('grouping-control');
await expect.element(control).toHaveAttribute('aria-disabled', 'false');
// turn the Letters layer off → nothing to regroup
await page.getByTestId('timeline-filter-trigger').click();
await page.getByTestId('timeline-filter-letters').click();
await expect.element(control).toHaveAttribute('aria-disabled', 'true');
// the chosen mode is retained for when letters return
expect(radio('thema').getAttribute('aria-checked')).toBe('true');
// re-enabling restores the enabled control with the same mode (no reset to Datum)
await page.getByTestId('timeline-filter-letters').click();
await expect.element(control).toHaveAttribute('aria-disabled', 'false');
expect(radio('thema').getAttribute('aria-checked')).toBe('true');
});
});