diff --git a/.specify/rtm.md b/.specify/rtm.md
index b7b8e54d..5dc41979 100644
--- a/.specify/rtm.md
+++ b/.specify/rtm.md
@@ -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 |
+
| 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 letters live inside a **contained card whose header is the same-year curated event** (glyph, title, date, provenance, edit pencil) — the title reads once, no separate floating pill; letters render as the compact `.lcard.ev` variant, first 5 + show-more (redesign #847 → #827 grouped-card layout) | #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 the curated event as the card header when given an `event` (no separate pill)`, `LetterBucket.svelte.spec.ts#shows no edit affordance in the header when canWrite is false`, `YearBand.svelte.spec.ts#renders a same-year curated event as one card header, with no separate pill and no duplicate 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 clusters are **contained colour-railed cards** (bordered, rounded, surface) carrying compact cards; a cluster shows the first `CLUSTER_PREVIEW` (5) letters behind a show-more toggle, and the leftover bin is a **collapsed count-only drawer** revealed on demand — the month-density `YearLetterStrip` is no longer used in grouped mode (still used in Datum dense years) (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#CLUSTER_PREVIEW`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#renders the cluster as a contained card (bordered, rounded, surface)`, `#binds a tag bucket together with a coloured left rail from its token`, `#shows only the first 5 letters with a show-more toggle when the cluster is larger`, `#expands to all letters and collapses back on toggle`, `#renders collapsed — count + reveal, no letter cards — until opened`, `#reveals the first 5 letters when opened`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
index 0739cbfb..6d5c5900 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEntryDTO.java
@@ -28,6 +28,13 @@ import java.util.UUID;
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* types stay optional.
*
+ *
Letter→event link ({@code linkedEventId}): 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.
+ *
*
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
) {
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
index f2ee6d7e..03092647 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
@@ -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;
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
index 7a084205..b63d9bb8 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java
@@ -80,9 +80,14 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers
Set 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 allEvents = eventRepository.findAll();
+
// ── curated events ───────────────────────────────────────────────────
List 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 rootByDocId = resolveLetterRootTags(letters);
+ Map 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 rootByDocId) {
+ private TimelineEntryDTO mapDocument(Document doc, Map rootByDocId,
+ Map 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 all 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 resolveLetterEventLinks(List letters, List events) {
+ Set letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
+ if (letterDocIds.isEmpty()) return Map.of();
+
+ Map eventByDocId = new HashMap<>();
+ for (TimelineEvent ev : events) {
+ Set 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),
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
index 06255ecb..7b2597f6 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceTest.java
@@ -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) {
diff --git a/docs/adr/045-timeline-client-side-regroup.md b/docs/adr/045-timeline-client-side-regroup.md
new file mode 100644
index 00000000..87ba6abd
--- /dev/null
+++ b/docs/adr/045-timeline-client-side-regroup.md
@@ -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`.
diff --git a/docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md b/docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md
new file mode 100644
index 00000000..b5420d37
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md
@@ -0,0 +1,102 @@
+# Zeitstrahl grouped-view layout redesign
+
+**Date:** 2026-06-15
+**Feature:** #827 (regroup `/zeitstrahl` by Ereignis/Thema) — layout follow-up on PR #847
+**Status:** Approved (brainstorm), pending implementation plan
+
+> The REQ contract for #827 lives in the Gitea issue body (and the amendment comment of
+> 2026-06-15). This document records the **layout/visual design** agreed in the visual
+> brainstorm and the REQ deltas it implies. Mockups: `.superpowers/brainstorm/*/content/`.
+
+## Problem
+
+The first grouped-view implementation (PR #847) fixed the flood and the duplicate event title,
+but two issues remained on review of the live view:
+
+1. **Weak belonging.** A clustered event's letters dropped below its centered pill as a
+ full-width block with only a thin left rail. The connection between an event and its letters
+ read weakly — the eye couldn't tell the letters belonged to the pill above.
+2. **Layout inconsistency.** In Datum mode letters alternate left/right of the centered spine
+ (events/density centered). In grouped mode the letters became full-width, breaking that
+ rhythm with no clear reason.
+
+## Decision: a cluster is one contained card
+
+A clustered event (Ereignis) or root tag (Thema) renders as **one bordered card** whose header
+is the event/tag itself and whose body holds that cluster's letters. Belonging becomes
+structural (a single container), not positional guesswork. This replaces the full-width block.
+
+### Ereignis mode, per year band
+
+1. **Derived life-events** (Geburt/Tod/Heirat, `abgeleitet`) never cluster — they carry no
+ document links, so they are always **plain axis fixtures, unchanged from Datum mode**. A
+ **world-band** (`historisch`) is normally letterless and stays a plain band; on the rare
+ occasion a historical event has linked letters it follows rule 2 (becomes a card).
+2. **A curated event (PERSONAL or HISTORICAL) with letters in this band** → one mint-bordered card:
+ - **Header** = the event's glyph + title + date + `kuratiert` + edit-✎ + count (the pill's
+ content, laid out as a header bar). This *replaces* the separate floating pill for that
+ event in this band — killing the duplicate title.
+ - **Body** = the cluster's letters, **first 5 shown, then a "+ N weitere Briefe anzeigen"
+ toggle** that expands/collapses the rest. Letters use the compact `LetterCard` variant.
+3. **A curated event with no letters in this band** → stays a plain centered pill (no empty card).
+4. **A curated event whose letters fall in a different year than its pill** → those letters form a
+ labeled card in *their* year (header = event name as text, no ✎/pill since the pill lives
+ elsewhere); the pill stays in its own band. No adjacent duplication.
+5. **Leftover letters** (linked to no surviving curated event) → a collapsed neutral, dashed
+ **"✉ N Briefe ohne Ereignis · anzeigen ›"** drawer. Clicking expands to the same first-5 +
+ show-more list. No preview letters until opened.
+
+### Thema mode
+
+Identical shape. Each card's header is the **tinted root-tag chip** (`● Krieg · 24`,
+`BucketHeaderChip`, fixed-ink label per the contrast fix) instead of an event pill; there is no
+axis pill for a tag, so every tag cluster is a standalone card. The per-letter `TagChip` stays
+suppressed inside its own card (REQ-017). The leftover drawer reads **"Ohne Thema"**.
+
+### Layout / spine
+
+- Cluster cards are **centered on the spine** (like events already are), not full-width-flush —
+ consistent with how grouped units (events) relate to the axis. Individual chronological
+ letters keep alternating left/right only in **Datum** mode.
+- Each card carries a colour left rail: **mint** for an Ereignis cluster, the **tag colour** for
+ a Thema cluster, **neutral dashed** for the leftover drawer.
+
+## Components affected
+
+- `LetterBucket.svelte` — becomes the contained card: header slot (pill-content / tag chip /
+ drawer label / cross-year text label) + body with the first-5 cap and the show-more toggle.
+ Drop the `YearLetterStrip` (sparkline) branch from grouped mode.
+- `YearBand.svelte` — in Ereignis mode, a same-year curated event renders *as* the card header
+ (merge pill into the card) instead of pill-then-nested-bucket; derived/world/letterless events
+ stay plain; cross-year clusters and the leftover drawer render after the axis entries.
+- `LetterCard.svelte` — compact variant already exists (PR #847); reused inside cards.
+- `BucketHeaderChip.svelte` — reused as the Thema card header (contrast fix already shipped).
+- `timelineGrouping.ts` — the first-visible cap (`CLUSTER_PREVIEW = 5`) replaces
+ `BUCKET_DENSE_THRESHOLD`; helpers unchanged otherwise.
+- Possibly a small `ClusterCard`/header sub-component if `LetterBucket` grows too large.
+
+## REQ deltas (to fold into issue #827)
+
+- **REQ-001 (amended):** derived life-events, world-bands, and *letterless* curated event pills
+ render identically across modes; a curated event **that has letters** renders as its cluster
+ card's header in grouped mode (no longer byte-identical to its Datum pill). Every event keeps
+ its spine position (year).
+- **REQ-003 / REQ-014 (amended):** event-clustered letters live inside a contained card; the
+ header is the event (same-year) or a text label (cross-year). First 5 shown + show-more.
+- **REQ-020 (amended):** grouped clusters are contained colour-railed cards with a first-5
+ preview + show-more toggle; the leftover bin is a collapsed count-only drawer. The
+ month-density `YearLetterStrip` is **no longer used in grouped mode** (still used in Datum
+ dense years).
+
+## Out of scope
+
+- Datum mode (untouched — keeps the alternating-axis zigzag and the >12 sparkline strip).
+- Backend / DTO (`linkedEventId` and root-tag fields already shipped; no change).
+- New i18n beyond a show-more / drawer label string set.
+
+## Testing approach
+
+TDD per component, mirroring PR #847: `LetterBucket` (card header variants, first-5 cap,
+show-more expand/collapse, drawer collapsed-by-default, colour rail), `YearBand` (same-year merge
+= no duplicate title; cross-year keeps a label; derived/world pills unchanged), and the route
+spec for the assembled view. Run targeted `--project=client` / `--project=server` specs only.
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 17823ad6..1209ad1f 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -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
diff --git a/frontend/e2e/zeitstrahl-grouping.spec.ts b/frontend/e2e/zeitstrahl-grouping.spec.ts
new file mode 100644
index 00000000..d49de9cf
--- /dev/null
+++ b/frontend/e2e/zeitstrahl-grouping.spec.ts
@@ -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([]);
+ });
+});
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 6be0122c..84632358 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -1050,6 +1050,21 @@
"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_bucket_show_more": "+ {count} weitere Briefe anzeigen",
+ "timeline_bucket_show_less": "Weniger anzeigen",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_letter_glyph_label": "Brief",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index b07fe58e..0899da7d 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -1050,6 +1050,21 @@
"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_bucket_show_more": "+ {count} more letters",
+ "timeline_bucket_show_less": "Show fewer",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_letter_glyph_label": "Letter",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 2048c1f6..5d3c6aaa 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -1050,6 +1050,21 @@
"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_bucket_show_more": "+ {count} cartas más",
+ "timeline_bucket_show_less": "Mostrar menos",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_letter_glyph_label": "Carta",
diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts
index 33f9b3ab..e994e031 100644
--- a/frontend/src/lib/generated/api.ts
+++ b/frontend/src/lib/generated/api.ts
@@ -2467,6 +2467,8 @@ export interface components {
rootTagId?: string;
rootTagName?: string;
rootTagColor?: string;
+ /** Format: uuid */
+ linkedEventId?: string;
};
TimelineYearDTO: {
/** Format: int32 */
diff --git a/frontend/src/lib/messages.spec.ts b/frontend/src/lib/messages.spec.ts
index 155ae1a3..22f1527b 100644
--- a/frontend/src/lib/messages.spec.ts
+++ b/frontend/src/lib/messages.spec.ts
@@ -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');
+ });
});
diff --git a/frontend/src/lib/timeline/BucketHeaderChip.svelte b/frontend/src/lib/timeline/BucketHeaderChip.svelte
new file mode 100644
index 00000000..c5bdeb98
--- /dev/null
+++ b/frontend/src/lib/timeline/BucketHeaderChip.svelte
@@ -0,0 +1,64 @@
+
+
+
+ {m.timeline_tag_chip_label()}:
+
+ {name}
+
diff --git a/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts b/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts
new file mode 100644
index 00000000..24a60ba4
--- /dev/null
+++ b/frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts
@@ -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 = '';
+ 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)');
+ });
+});
diff --git a/frontend/src/lib/timeline/GroupingControl.svelte b/frontend/src/lib/timeline/GroupingControl.svelte
new file mode 100644
index 00000000..681c289d
--- /dev/null
+++ b/frontend/src/lib/timeline/GroupingControl.svelte
@@ -0,0 +1,114 @@
+
+
+
+ {#each segments as segment (segment.value)}
+
+ {/each}
+
+{#if disabled}
+ {m.timeline_grouping_disabled_reason()}
+{/if}
+
+
diff --git a/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts b/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts
new file mode 100644
index 00000000..6516881d
--- /dev/null
+++ b/frontend/src/lib/timeline/GroupingControl.svelte.spec.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte
new file mode 100644
index 00000000..0c86c43c
--- /dev/null
+++ b/frontend/src/lib/timeline/LetterBucket.svelte
@@ -0,0 +1,195 @@
+
+
+
+ {#if !nested}
+ {#if event && accent}
+
+
+
+ {accent.glyph}
+ {accent.label}
+
+
+ {event.title}
+
+ {eventSubtitle} · {count}
+
+
+ {#if canEdit}
+
+ ✎
+ {m.btn_edit()}
+
+ {/if}
+
+ {:else}
+
+ {#if mode === 'thema' && bucket.kind === 'tag'}
+
+ {:else if mode === 'event' && bucket.kind === 'event'}
+
+ ✉
+ {bucket.title}
+
+ {:else}
+ {fallbackLabel}
+ {/if}
+ · {count}
+
+ {/if}
+ {/if}
+
+
+ {#if !revealed}
+
+ {:else}
+
+ {#each visible as letter (entryKey(letter))}
+
+
+
+ {/each}
+
+ {#if hiddenCount > 0}
+
+ {/if}
+ {/if}
+
+
diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
new file mode 100644
index 00000000..326a4683
--- /dev/null
+++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
@@ -0,0 +1,232 @@
+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 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 — preview cap + show-more (#827 redesign)', () => {
+ it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
+ const bucket: Bucket = {
+ key: 'tag:t1',
+ kind: 'tag',
+ title: 'Krieg',
+ color: 'sienna',
+ letters: manyLetters(8)
+ };
+ render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
+ expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
+ expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
+ expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone
+ });
+
+ it('expands to all letters and collapses back on toggle', async () => {
+ const bucket: Bucket = {
+ key: 'tag:t1',
+ kind: 'tag',
+ title: 'Krieg',
+ color: 'sienna',
+ letters: manyLetters(8)
+ };
+ render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
+ (document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
+ await tick();
+ expect(document.querySelectorAll('a.lcard')).toHaveLength(8);
+ (document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
+ await tick();
+ expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
+ });
+
+ it('shows all letters and no toggle for a small cluster (<= 5)', () => {
+ const bucket: Bucket = {
+ key: 'tag:t1',
+ kind: 'tag',
+ title: 'Tod',
+ color: null,
+ letters: manyLetters(3)
+ };
+ render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
+ expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
+ expect(document.querySelector('[data-testid="bucket-show-more"]')).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)');
+ });
+});
+
+describe('LetterBucket — leftover drawer (#827 redesign)', () => {
+ const fb = (n: number): Bucket => ({
+ key: '__fallback__',
+ kind: 'fallback',
+ color: null,
+ letters: Array.from({ length: n }, (_, i) =>
+ makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` })
+ )
+ });
+ it('renders collapsed — count + reveal, no letter cards — until opened', () => {
+ render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
+ expect(document.querySelector('a.lcard')).toBeNull();
+ expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
+ expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull();
+ });
+ it('reveals the first 5 letters when opened', async () => {
+ render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
+ (document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click();
+ await tick();
+ expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
+ expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
+ });
+});
+
+describe('LetterBucket — card chrome (#827 redesign)', () => {
+ it('renders the cluster as a contained card (bordered, rounded, surface)', () => {
+ const bucket: Bucket = {
+ key: 'tag:t1',
+ kind: 'tag',
+ title: 'Krieg',
+ color: 'sienna',
+ letters: [makeEntry({ documentId: 'a' })]
+ };
+ render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
+ const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
+ expect(card.className).toMatch(/\brounded\b|rounded-/);
+ expect(card.className).toContain('border');
+ expect(card.className).toContain('bg-surface');
+ });
+});
+
+describe('LetterBucket — event-as-header (#827 redesign)', () => {
+ it('renders the curated event as the card header when given an `event` (no separate pill)', () => {
+ const event = makeEntry({
+ kind: 'EVENT',
+ type: 'PERSONAL',
+ derived: false,
+ eventId: 'e1',
+ title: 'Ein gewaltiger Stadtbrand',
+ eventDate: '1916-07-06',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ });
+ const bucket: Bucket = {
+ key: 'event:e1',
+ kind: 'event',
+ title: 'Ein gewaltiger Stadtbrand',
+ color: null,
+ letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })]
+ };
+ render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true });
+ const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement;
+ expect(header.textContent).toContain('Ein gewaltiger Stadtbrand');
+ expect(header.textContent).toContain(m.timeline_provenance_curated());
+ expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe(
+ '/zeitstrahl/events/e1/edit'
+ );
+ });
+
+ it('shows no edit affordance in the header when canWrite is false', () => {
+ const event = makeEntry({
+ kind: 'EVENT',
+ type: 'PERSONAL',
+ derived: false,
+ eventId: 'e1',
+ title: 'X',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ });
+ const bucket: Bucket = {
+ key: 'event:e1',
+ kind: 'event',
+ title: 'X',
+ color: null,
+ letters: [makeEntry({ documentId: 'a' })]
+ };
+ render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false });
+ expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte
index 593156fc..df586f79 100644
--- a/frontend/src/lib/timeline/LetterCard.svelte
+++ b/frontend/src/lib/timeline/LetterCard.svelte
@@ -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(
{#if entry.title}
-
+ {entry.title}
{/if}
-
+ {sender}→{receiver}
- {#if dateLabel}
+ {#if dateLabel && showDate}
· {dateLabel}
{/if}
- {#if entry.rootTagName}
+ {#if entry.rootTagName && !suppressTagChip}
+ (#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). -->
{/if}
diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts
index b60c0f5f..7821ff7e 100644
--- a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts
+++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts
@@ -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();
+ });
+});
diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte
index c3008e90..aac64896 100644
--- a/frontend/src/lib/timeline/TimelineView.svelte
+++ b/frontend/src/lib/timeline/TimelineView.svelte
@@ -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 — 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() : 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}`)}
{#if row.t === 'band'}
-
+
{:else}
{/if}
diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte
index fafa0b4c..f643885b 100644
--- a/frontend/src/lib/timeline/YearBand.svelte
+++ b/frontend/src/lib/timeline/YearBand.svelte
@@ -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,80 @@ 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()
+}: {
+ year: TimelineYearDTO;
+ canWrite?: boolean;
+ groupingMode?: GroupingMode;
+ eventLookup?: Map;
+} = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
+ | { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
| { 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(() => {
const out: Row[] = [];
+
+ // Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
+ // becomes the contained card's header (no separate pill — its title reads once, #827
+ // redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
+ // lives in another year band (or was filtered out) renders as a text-header card here, and
+ // the unlinked letters fall to the single "Weitere Briefe" drawer (REQ-003/006/019).
+ if (groupingMode === 'event') {
+ const buckets = bucketLetters(letters, 'event', eventLookup);
+ const sameYearBucket = (id: string | undefined) =>
+ id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
+ for (const entry of year.entries) {
+ if (entry.kind !== 'EVENT') continue;
+ const bucket = sameYearBucket(entry.eventId);
+ // A curated event with same-year letters becomes the card header (card replaces pill);
+ // otherwise it stays a plain pill/world-band.
+ if (bucket) out.push({ t: 'eventcard', entry, bucket });
+ else out.push({ t: 'event', entry });
+ }
+ // Cross-year clusters (no matching event entry in this band) and the fallback drawer
+ // render after the axis entries, with their own text header.
+ for (const bucket of buckets) {
+ if (
+ bucket.kind === 'fallback' ||
+ !year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === 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 +110,12 @@ const rows = $derived.by(() => {
}
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);
+}
@@ -56,18 +129,28 @@ const rows = $derived.by(() => {
- {#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'}
{:else}
{/if}
+ {:else if row.t === 'eventcard'}
+
{:else if row.t === 'letter'}
+ {:else if row.t === 'bucket'}
+
{:else}
{/if}
diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts
index 45845080..c8fdae0f 100644
--- a/frontend/src/lib/timeline/YearBand.svelte.spec.ts
+++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts
@@ -165,3 +165,100 @@ 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('renders a same-year curated event as one card header, with no separate pill and no duplicate 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']]),
+ canWrite: true
+ });
+ // the title appears exactly once — in the card header, not also as a separate pill
+ const occurrences =
+ (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
+ expect(occurrences).toBe(1);
+ // the event renders as the card header, with its letter clustered inside
+ expect(document.querySelector('[data-testid="bucket-event-header"]')).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');
+ });
+});
diff --git a/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts
new file mode 100644
index 00000000..7f2a724d
--- /dev/null
+++ b/frontend/src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts
@@ -0,0 +1,98 @@
+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
+ });
+}
+
+// Brief A links to the curated event p1 (Hochzeit), not the world band — so the world band
+// stays letterless and renders as a plain band in every mode (REQ-001). Under the #827 redesign
+// a curated event WITH letters becomes its cluster card's header, so the signature tracks the
+// stable layer: the letterless world band's marker count and the two titles, which all survive
+// regardless of whether Hochzeit renders as a pill (Datum) or a card header (grouped).
+const mixed = () =>
+ makeTimelineDTO({
+ years: [
+ makeYear(1915, [
+ worldBand('Erster Weltkrieg'),
+ eventPill('Hochzeit'),
+ makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }),
+ 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();
+ });
+});
diff --git a/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts b/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
new file mode 100644
index 00000000..99b68152
--- /dev/null
+++ b/frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
@@ -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([]);
+ });
+});
diff --git a/frontend/src/lib/timeline/timelineGrouping.spec.ts b/frontend/src/lib/timeline/timelineGrouping.spec.ts
new file mode 100644
index 00000000..7a050ec4
--- /dev/null
+++ b/frontend/src/lib/timeline/timelineGrouping.spec.ts
@@ -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();
+
+ 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();
+ });
+});
diff --git a/frontend/src/lib/timeline/timelineGrouping.ts b/frontend/src/lib/timeline/timelineGrouping.ts
new file mode 100644
index 00000000..6b487213
--- /dev/null
+++ b/frontend/src/lib/timeline/timelineGrouping.ts
@@ -0,0 +1,152 @@
+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';
+
+/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
+export const CLUSTER_PREVIEW = 5;
+
+/** 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 {
+ const lookup = new Map();
+ 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,
+ eventLookup: Map
+): LetterBucket[] {
+ const byKey = new Map();
+ 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;
+}
diff --git a/frontend/src/routes/zeitstrahl/+page.svelte b/frontend/src/routes/zeitstrahl/+page.svelte
index 759b3340..0c353d10 100644
--- a/frontend/src/routes/zeitstrahl/+page.svelte
+++ b/frontend/src/routes/zeitstrahl/+page.svelte
@@ -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('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(' · ');
});
@@ -89,7 +105,14 @@ const metaLine = $derived.by(() => {
{/if}
{#if hasContent}
-
{metaLine}
+
{metaLine}
+
+
+
+
{
{:else}
-
+
{/if}
diff --git a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts
index 3f8a8d7b..8c79f89e 100644
--- a/frontend/src/routes/zeitstrahl/page.svelte.spec.ts
+++ b/frontend/src/routes/zeitstrahl/page.svelte.spec.ts
@@ -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');
+ });
+});