Compare commits

...

26 Commits

Author SHA1 Message Date
Marcel
093c942f67 docs(rtm): trace the grouped-view contained-card layout (#827)
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 7m23s
CI / OCR Service Tests (pull_request) Successful in 49s
CI / Backend Unit Tests (pull_request) Failing after 14m37s
CI / fail2ban Regex (pull_request) Successful in 1m53s
CI / Semgrep Security Scan (pull_request) Successful in 43s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m57s
SDD Gate / RTM Check (pull_request) Successful in 34s
SDD Gate / Contract Validate (pull_request) Successful in 47s
SDD Gate / Constitution Impact (pull_request) Successful in 37s
Refs #827
2026-06-15 14:59:26 +02:00
Marcel
bd78f34f09 feat(timeline): render a same-year curated event as its cluster card header
A curated event with letters in its own band now becomes the contained card header
(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —
the title reads once. Derived life-events, world-bands, and letterless event pills
are unchanged (REQ-001 amended for curated-with-letters; the identity fixture now
links its letter to the curated event so the letterless world band stays a band).

Refs #827
2026-06-15 14:56:57 +02:00
Marcel
9ad18f92d9 feat(timeline): make a grouped cluster one contained card
Wraps each cluster in a bordered, rounded surface card (keeping the colour rail)
so the header and its letters read as a single unit.

Refs #827
2026-06-15 14:52:28 +02:00
Marcel
b08f86f76d feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer
The catch-all bucket renders count-only by default behind a reveal control, then
expands to the first-5 + show-more body. Keeps the junk drawer quiet instead of
flooding the timeline.

Refs #827
2026-06-15 14:50:35 +02:00
Marcel
c1dc58c24f feat(timeline): cap grouped clusters at 5 letters with a show-more toggle
Replaces the in-bucket month-density sparkline with a first-5 preview + show-more
/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12
YearLetterStrip.

Refs #827
2026-06-15 14:48:10 +02:00
Marcel
4162cfa916 docs(timeline): implementation plan for the grouped-view contained-card layout
Five TDD tasks: preview cap + show-more, collapsed leftover drawer, card chrome,
same-year event as card header (kills duplicate), regression + RTM.

Refs #827 #847
2026-06-15 14:42:06 +02:00
Marcel
74bf1d864c docs(timeline): design doc for the grouped-view contained-card layout
Records the visual-brainstorm outcome for #827's grouped view: a cluster becomes one
contained card (event/tag as header, first 5 letters + show-more), the leftover bin
collapses to a count-only drawer, derived/world fixtures stay plain, and REQ-001/003/014/020
are amended. Mockups under .superpowers/brainstorm/ (gitignored).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -2,7 +2,7 @@
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
> matrix is the part of the spec that *is* committed: it links each requirement to its issue,
> matrix is the part of the spec that _is_ committed: it links each requirement to its issue,
> the code that implements it, and the test(s) that prove it — so any requirement traces end
> to end, and any orphan (a requirement with no test) is visible on `main`.
@@ -24,30 +24,31 @@
## Matrix
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|---|---|---|---|---|---|---|
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
| ------- | ---------------------------------------------------------------------- | -------- | ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (\_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (\_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (\_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (\_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-005 | SPOUSE*OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
@@ -182,7 +183,7 @@
| REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done |
| REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done |
| REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done |
| REQ-010 | 8 timeline_filter_* keys in de/en/es; trigger vs trigger_active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
| REQ-010 | 8 timeline_filter*_ keys in de/en/es; trigger vs trigger*active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
| REQ-001 | WRITE_ALL viewer → "Ereignis hinzufügen" link to /zeitstrahl/events/new in the wrapping Zeitstrahl header | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/messages/{de,en,es}.json` | `zeitstrahl/page.svelte.spec.ts#renders the add-event CTA in a wrapping header when the viewer can write` | Done |
| REQ-002 | viewer without WRITE_ALL → no add-event affordance on /zeitstrahl | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#renders no add-event CTA when the viewer cannot write` | Done |
| REQ-003 | WRITE_ALL viewer → person-page "Ereignis für diese Person" link to /zeitstrahl/events/new?personId={id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/messages/{de,en,es}.json` | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` | Done |
@@ -194,3 +195,24 @@
| REQ-009 | TimelineView threads canWrite through the year-band path and the undated bucket (identical gate) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `TimelineView.svelte.spec.ts#threads canWrite to a curated event in both a year band and the undated bucket`, `#threads canWrite to a curated HISTORICAL world band in both paths` | Done |
| REQ-010 | person add-event opens #781 create form prefilled with the person, returns to /persons/{id} on save | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (#781) | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` (URL), `zeitstrahl/events/new/page.server.spec.ts#redirects to /persons/{id} when originPersonId is a valid UUID` (#781) | Done |
| REQ-011 | non-curator direct nav to /zeitstrahl/events/new or /{id}/edit → 403 (existing #781 route guard, regression) | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (#781, unchanged — both share `requireWriteAll`) | `zeitstrahl/events/new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `zeitstrahl/events/[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
| REQ-001 | axis-fixed layers (life-events, pills, world-bands) render identically across all 3 modes; only loose letters re-bundle | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `grouping-event-layer-identity.svelte.spec.ts#renders the event pills and world-bands identically across all three grouping modes`, `YearBand.svelte.spec.ts#still renders the event world-band in Ereignis mode` | Done |
| REQ-002 | mode switch re-bundles loose letters over the layer-filtered view, no GET /api/timeline refetch | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/TimelineView.svelte` | `zeitstrahl/page.svelte.spec.ts#regroups loose letters under their event client-side`, `e2e/zeitstrahl-grouping.spec.ts#switching grouping modes issues no extra timeline fetch` | Done |
| REQ-003 | Ereignis clusters each loose letter under the curated event whose documents contain it | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#clusters letters under the curated event named by linkedEventId`, `YearBand.svelte.spec.ts#clusters loose letters under their linked event in Ereignis mode` | Done |
| REQ-004 | Thema buckets each loose letter per year under its primary root tag (rootTagId) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#buckets letters under their primary root tag with name and colour`, `YearBand.svelte.spec.ts#buckets loose letters under their root tag in Thema mode` | Done |
| REQ-005 | TimelineEntryDTO carries nullable linkedEventId, resolved in one batched membership pass | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id` | Done |
| REQ-005b | linkedEventId is nullable / not @Schema REQUIRED; null for non-letter entries | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `frontend/src/lib/generated/api.ts` (`linkedEventId?`) | `TimelineServiceTest#letter_in_no_curated_event_has_null_linkedEventId` | Done |
| REQ-006 | Ereignis: letter with null linkedEventId → per-year "Weitere Briefe" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops a letter with no linkedEventId into the fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Weitere Briefe" label` | Done |
| REQ-007 | Thema: untagged letter → per-year "Ohne Thema" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops an untagged letter into the "Ohne Thema" fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Ohne Thema" label` | Done |
| REQ-008 | multi-tagged letter appears under exactly one root tag, never duplicated | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts` | `timelineGrouping.spec.ts#places a letter in exactly one bucket` | Done |
| REQ-009 | tag names + hint render via `{...}` escaping; grep gate forbids `{@html}` in lib/timeline | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte`, `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/TagChip.svelte` | `BucketHeaderChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive` | Done |
| REQ-010 | grouping control is a keyboard-navigable role=radiogroup, ≥44px text segments, default Datum, dark-mode contrast | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#renders three radios inside a radiogroup`, `#moves the selection forward with the right arrow key`, `#each segment has a tap target of at least 44×44px`, `#defaults to Datum`; `e2e/zeitstrahl-grouping.spec.ts#no wcag2a/wcag2aa violations ... (light + dark)` | Done |
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
| REQ-014 | Ereignis event-clustered 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 |

View File

@@ -28,6 +28,13 @@ import java.util.UUID;
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* types stay optional.
*
* <p><b>Letter→event link ({@code linkedEventId}):</b> for a {@link Kind#LETTER} entry, the id of
* the curated {@link TimelineEvent} whose {@code documents} set contains the letter's document, or
* {@code null} when the letter is referenced by no curated event (#827). Computed on read from the
* existing {@code timeline_event_documents} join — no new column. {@code null} for non-letter
* entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* type stays optional.
*
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/
@@ -47,6 +54,7 @@ public record TimelineEntryDTO(
DerivedEventType derivedType,
UUID rootTagId,
String rootTagName,
String rootTagColor
String rootTagColor,
UUID linkedEventId
) {
}

View File

@@ -267,7 +267,7 @@ public class TimelineEventService {
p.getBirthDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
null, null, null))
null, null, null, null))
.toList();
}
@@ -279,7 +279,7 @@ public class TimelineEventService {
p.getDeathDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.DEATH,
null, null, null))
null, null, null, null))
.toList();
}
@@ -304,7 +304,7 @@ public class TimelineEventService {
null, null,
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
DerivedEventType.MARRIAGE,
null, null, null));
null, null, null, null));
}
}
return result;

View File

@@ -80,9 +80,14 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// Fetch curated events once — reused for both the event entries below and the
// batched letter→event link resolution (resolveLetterEventLinks), so the
// membership pass costs no extra query. REQ-005.
List<TimelineEvent> allEvents = eventRepository.findAll();
// ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>();
for (TimelineEvent ev : eventRepository.findAll()) {
for (TimelineEvent ev : allEvents) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
@@ -107,8 +112,9 @@ public class TimelineService {
letters.add(doc);
}
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId));
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
}
return bucket(entries);
@@ -229,11 +235,13 @@ public class TimelineService {
null,
null,
null,
null,
null
);
}
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
Map<UUID, UUID> eventByDocId) {
RootTag root = rootByDocId.get(doc.getId());
return new TimelineEntryDTO(
Kind.LETTER,
@@ -251,10 +259,38 @@ public class TimelineService {
null,
root == null ? null : root.id(),
root == null ? null : root.name(),
root == null ? null : root.color()
root == null ? null : root.color(),
eventByDocId.get(doc.getId())
);
}
/**
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the
* event whose {@code documents} set contains the letter (REQ-005). A single doc→event map is
* built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents}
* carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
* event, the first by repository iteration order wins ({@code putIfAbsent}). The map is built
* from <em>all</em> events (not just the year/type-filtered ones) so the link is a stable
* property of the data; the frontend's filter-then-group decides whether the linked event is
* actually on screen (#827). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
*/
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
if (letterDocIds.isEmpty()) return Map.of();
Map<UUID, UUID> eventByDocId = new HashMap<>();
for (TimelineEvent ev : events) {
Set<Document> linkedDocs = ev.getDocuments();
if (linkedDocs == null) continue;
for (Document linked : linkedDocs) {
if (letterDocIds.contains(linked.getId())) {
eventByDocId.putIfAbsent(linked.getId(), ev.getId());
}
}
}
return eventByDocId;
}
/**
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),

View File

@@ -69,10 +69,10 @@ class TimelineServiceTest {
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
null, null, null);
null, null, null, null);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
null, null, null);
null, null, null, null);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
@@ -511,6 +511,44 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList());
}
// ─── letter→event link (#827, REQ-005/006) ───────────────────────────────
@Test
void letter_in_a_curated_events_documents_carries_that_events_id() {
// REQ-005: linkedEventId = the curated event whose documents set contains the letter.
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
UUID eventId = UUID.randomUUID();
TimelineEvent event = TimelineEvent.builder().id(eventId)
.title("Briefe von der Front").type(EventType.PERSONAL)
.documents(new HashSet<>(Set.of(letterDoc)))
.build(); // no eventDate → event lands undated, leaving the year band to the letter
when(eventRepository.findAll()).thenReturn(List.of(event));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.linkedEventId()).isEqualTo(eventId);
}
@Test
void letter_in_no_curated_event_has_null_linkedEventId() {
// REQ-006: a letter referenced by no curated event → linkedEventId null (frontend falls
// back to the per-year "Weitere Briefe" bucket).
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
.title("Anderes Ereignis").type(EventType.PERSONAL)
.documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build())))
.build();
when(eventRepository.findAll()).thenReturn(List.of(event));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.linkedEventId()).isNull();
}
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0);
@@ -523,7 +561,7 @@ class TimelineServiceTest {
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
null, null, null);
null, null, null, null);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {

View File

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

View File

@@ -0,0 +1,374 @@
# Zeitstrahl grouped-view contained-card layout — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the grouped-view's full-width bucket blocks with self-contained cards — a cluster (event or root tag) becomes one bordered card whose header is the event/tag and whose body shows the first 5 letters with a show-more toggle; the leftover bin collapses to a count-only drawer; a same-year curated event renders *as* its card header (no duplicate pill).
**Architecture:** Frontend-only, on branch `feat/issue-827-zeitstrahl-grouping` (worktree `.worktrees/issue-827-zeitstrahl-grouping`). Evolve `LetterBucket.svelte` into the contained card (preview cap + show-more + collapsed drawer + card chrome + optional event header) and rewire `YearBand.svelte`'s Ereignis branch so a same-year curated event becomes the card header instead of a separate pill. Datum mode is untouched. Design doc: `docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md`.
**Tech Stack:** Svelte 5 (runes), Tailwind 4, Paraglide i18n, Vitest browser mode (`--project=client` for `*.svelte.spec.ts`, `--project=server` for plain `*.spec.ts`).
**Conventions (read before starting):**
- Red→green TDD, one logical change per commit, `Refs #827` on the last body line.
- Run only the specific spec file(s) — never the full suite. Client: `npx vitest run <file> --project=client`; server: `... --project=server`.
- Before each commit: `npx prettier --write <changed files>`, then `git add <files>` + `git diff --cached --stat` to verify the staged set, then commit (the pre-commit hook runs `prettier --check` + `eslint`).
- **No `new Set()`/`new Map()` inside a `.svelte` file** — `svelte/prefer-svelte-reactivity` errors even on transient locals. Use plain arrays (`find`/`some`/`filter`) inside `$derived`. (Pure `.ts` modules are fine.)
- Prettier rewrites `class:foo` shorthand to `class:foo={foo}` — expect that.
- Factories in `frontend/src/lib/timeline/test-factories.ts`: `makeEntry`, `makeYear`, `makeTimelineDTO`.
---
## File Structure
- `frontend/src/lib/timeline/timelineGrouping.ts` — add `CLUSTER_PREVIEW = 5`; remove `BUCKET_DENSE_THRESHOLD`/`isBucketDense` (no longer used). Keep `bucketLetters`, `buildEventLookup`, `hasLooseLetters`, `tagColorVar`.
- `frontend/src/lib/timeline/LetterBucket.svelte` — the contained card: card chrome + colour rail + header variants (tag chip / event-header / cross-year text label / drawer label) + body (first-5 preview, show-more toggle, drawer collapsed-by-default). Drop the `YearLetterStrip` branch.
- `frontend/src/lib/timeline/YearBand.svelte` — Ereignis branch: a same-year curated event renders as a `LetterBucket` card with `event={entry}` (no separate pill); letterless/derived/world events stay plain; cross-year clusters + the fallback drawer render after the axis entries.
- `frontend/messages/{de,en,es}.json` — two new keys: `timeline_bucket_show_more` (`{count}`), `timeline_bucket_show_less`.
- Specs: `LetterBucket.svelte.spec.ts`, `YearBand.svelte.spec.ts`, `messages.spec.ts` (extend), plus the route spec stays green.
- `.specify/rtm.md` — update REQ-014/REQ-020 rows.
---
## Task 1: Preview cap + show-more toggle (drop the sparkline)
**Files:**
- Modify: `frontend/src/lib/timeline/timelineGrouping.ts`
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
- [ ] **Step 1: Add the i18n keys** in all three locales (so the toggle has a label).
`frontend/messages/de.json` (next to the existing `timeline_bucket_*` keys):
```json
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
"timeline_bucket_show_less": "Weniger anzeigen",
```
`en.json`: `"+ {count} more letters"`, `"Show fewer"`. `es.json`: `"+ {count} cartas más"`, `"Mostrar menos"`.
Run `npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide` (from `frontend/`) or let the dev/test build compile them.
- [ ] **Step 2: Write the failing tests** in `LetterBucket.svelte.spec.ts` (replace the `manyLetters`-based density tests from PR #847 — the sparkline is going away).
```ts
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();
});
});
```
Add `import { tick } from 'svelte';` at the top of the spec if absent.
- [ ] **Step 3: Run the tests — verify they fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL (still rendering the strip / all cards).
- [ ] **Step 4: Implement.** In `timelineGrouping.ts` remove `BUCKET_DENSE_THRESHOLD` + `isBucketDense`, add:
```ts
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
export const CLUSTER_PREVIEW = 5;
```
In `LetterBucket.svelte`: remove the `YearLetterStrip` import + the `dense`/strip branch. Add expand state and a visible-slice derived; render `CLUSTER_PREVIEW` compact cards, then a toggle when there are more:
```svelte
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
import * as m from '$lib/paraglide/messages.js';
// ...
let expanded = $state(false);
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
```
Body markup (replace the `{#if dense}…{:else}…` block):
```svelte
<ul class="space-y-1.5">
{#each visible as letter (entryKey(letter))}
<li><LetterCard entry={letter} variant={cardVariant} suppressTagChip={mode === 'thema'} compact={true} /></li>
{/each}
</ul>
{#if hiddenCount > 0}
<button
type="button"
data-testid="bucket-show-more"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
style="display: inline-flex; align-items: center; min-height: 44px"
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{expanded ? m.timeline_bucket_show_less() : m.timeline_bucket_show_more({ count: hiddenCount })}
</button>
{/if}
```
- [ ] **Step 5: Run the tests — verify they pass.** Same command → PASS. Also run `src/lib/timeline/timelineGrouping.spec.ts --project=server` (still green; only constants changed).
- [ ] **Step 6: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/timelineGrouping.ts src/lib/timeline/LetterBucket.svelte.spec.ts messages/de.json messages/en.json messages/es.json
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/timelineGrouping.ts frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/messages/{de,en,es}.json
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): cap grouped clusters at 5 letters with a show-more toggle\n\nReplaces the in-bucket month-density sparkline with a first-5 preview + show-more\n/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12\nYearLetterStrip.\n\nRefs #827')"
```
---
## Task 2: Collapsed drawer for the leftover bin
**Files:**
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
The fallback bucket (`kind === 'fallback'` — "Weitere Briefe"/"Ohne Thema") is a junk drawer: render it **collapsed** (count only, no letters) until the user reveals it; revealing shows the same first-5 + show-more body.
- [ ] **Step 1: Write the failing tests.**
```ts
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
const fb = (n: number, mode: 'event' | 'thema'): 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, 'event'), 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, 'event'), 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();
});
});
```
- [ ] **Step 2: Run — verify fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL.
- [ ] **Step 3: Implement.** In `LetterBucket.svelte` add a `revealed` state defaulting to `bucket.kind !== 'fallback'` (non-drawers start open). Gate the body on it; when collapsed, render only the header + a reveal button:
```svelte
let revealed = $state(bucket.kind !== 'fallback');
// header always renders; body only when revealed
```
Collapsed drawer markup (when `!revealed`): the fallback label + count already render in the header; add the reveal control:
```svelte
{#if !revealed}
<button type="button" data-testid="bucket-reveal" onclick={() => (revealed = true)}
style="display:inline-flex;align-items:center;min-height:44px"
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy">
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
</button>
{:else}
<!-- the Task-1 body (ul + show-more) -->
{/if}
```
Give the drawer a dashed neutral rail: add `class:border-dashed={bucket.kind === 'fallback'}` to the card.
- [ ] **Step 4: Run — verify pass.** Same command → PASS. Re-run the Task-1 tests too (still green).
- [ ] **Step 5: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer\n\nThe catch-all bucket renders count-only by default behind a reveal control, then\nexpands to the first-5 + show-more body. Keeps the junk drawer quiet instead of\nflooding the timeline.\n\nRefs #827')"
```
---
## Task 3: Card chrome (the cluster is one contained card)
**Files:**
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
Turn the `<section>` (rail-only, from PR #847) into a bordered card: `rounded` + `border border-line` + `bg-surface` + `shadow-sm`, keeping the coloured left rail (mint for event cluster, tag colour for tag, dashed neutral for the drawer). Header on a subtle `bg-canvas`/tint bar.
- [ ] **Step 1: Write the failing test.**
```ts
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');
});
```
- [ ] **Step 2: Run — verify fail.** → FAIL (current section is `my-3 border-l-2 pl-3`, no `bg-surface`/`rounded`/full `border`).
- [ ] **Step 3: Implement.** Update the `<section data-testid="letter-bucket">` classes to e.g.:
```
class="my-3 overflow-hidden rounded-md border border-line border-l-2 bg-surface shadow-sm"
```
keep `class:border-l-brand-mint={isEventCluster}`, `class:border-dashed={bucket.kind==='fallback'}`, and the inline `style={railStyle}` for the tag colour. Move the body padding inside (e.g. wrap header + body in a `px-3 py-2`), and give the header a tint bar (`bg-canvas` for events, plain for the drawer). Verify the existing "coloured left rail" test (`expect(section.style).toContain('var(--c-tag-sienna)')`) still holds — keep `railStyle` on the section.
- [ ] **Step 4: Run — verify pass.** Run the whole `LetterBucket.svelte.spec.ts` → all PASS (including the PR #847 rail test).
- [ ] **Step 5: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): make a grouped cluster one contained card\n\nWraps each cluster in a bordered, rounded surface card (keeping the colour rail)\nso the header and its letters read as a single unit.\n\nRefs #827')"
```
---
## Task 4: Same-year curated event becomes the card header (kills the duplicate)
**Files:**
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte` (add `event` + `canWrite` props + event-header rendering)
- Modify: `frontend/src/lib/timeline/YearBand.svelte` (render the card in place of the pill for a same-year curated event)
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`, `frontend/src/lib/timeline/YearBand.svelte.spec.ts`
When a curated event has letters in the same band, the event IS the card header — no separate pill. Reuse `getAccentConfig` (glyph/label) + `timelineDateLabel` + the `kuratiert/abgeleitet` provenance + the `✎` edit affordance (curated + eventId + canWrite), mirroring `EventPill.svelte`.
- [ ] **Step 1: Write the failing tests.**
`LetterBucket.svelte.spec.ts`:
```ts
import { getAccentConfig } from './eventCardConfig';
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();
});
```
`YearBand.svelte.spec.ts` (replace/extend the PR #847 "nests an event cluster under its pill" test — the pill is now the card header):
```ts
it('renders a same-year curated event as one card header, with no separate pill and no duplicate title', () => {
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 });
const occurrences = (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
expect(occurrences).toBe(1); // once — in the card header
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
expect(document.querySelector('a.lcard.ev')).not.toBeNull(); // its letter, inside
});
```
- [ ] **Step 2: Run — verify fail.** Both specs → FAIL.
- [ ] **Step 3: Implement `LetterBucket` header.** Add props `event?: TimelineEntryDTO` and `canWrite = false`. Derive (mirroring `EventPill.svelte`):
```svelte
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
// ...
const accent = $derived(event ? getAccentConfig(event) : null);
const eventDateLabel = $derived(event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null);
const provenance = $derived(event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated());
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
```
Header branch order: `if (event)` → event header (`data-testid="bucket-event-header"`: glyph from `accent.glyph` aria-hidden + sr-only `accent.label`, `event.title`, `eventSubtitle`, count, and the `✎` link `/zeitstrahl/events/{event.eventId}/edit` with `data-testid="event-edit"` when `canEdit`); else the existing `thema/tag` chip / `fallback` label branches.
- [ ] **Step 4: Implement `YearBand` Ereignis branch.** When a curated event entry has a matching same-year bucket, render the card *instead of* the pill, passing `event={entry}` + `canWrite`; do **not** also push the `{ t: 'event' }` pill row for it. Letterless/derived/world events still push their pill/band row. Sketch:
```svelte
if (groupingMode === 'event') {
const buckets = bucketLetters(letters, 'event', eventLookup);
const sameYear = (id) => buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`);
for (const entry of year.entries) {
if (entry.kind !== 'EVENT') continue;
const bucket = entry.eventId ? sameYear(entry.eventId) : undefined;
if (bucket) out.push({ t: 'eventcard', entry, bucket }); // card replaces pill
else out.push({ t: 'event', entry }); // plain pill/band
}
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 }); // cross-year cluster / drawer
}
return out;
}
```
Add a `Row` variant `{ t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }` and template branch:
```svelte
{:else if row.t === 'eventcard'}
<LetterBucket bucket={row.bucket} mode="event" year={year.year} event={row.entry} canWrite={canWrite} />
```
Keep the existing `{ t: 'bucket' }` branch (cross-year clusters + drawer) rendering `<LetterBucket … nested={false} />` with no `event` prop → text header. Remember: **no `new Map`/`Set` in the component** — use `buckets.find` / `year.entries.some` as above.
- [ ] **Step 5: Run — verify pass.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts --project=client` → PASS. The identity spec (REQ-001) still passes because derived/world fixtures are unchanged; if a now-stale assertion expects a pill for a *curated-with-letters* event, update it to expect the card header (REQ-001 amendment) and note it in the commit.
- [ ] **Step 6: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/YearBand.svelte src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/YearBand.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/src/lib/timeline/YearBand.svelte.spec.ts
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): render a same-year curated event as its cluster card header\n\nA curated event with letters in its own band now becomes the contained card header\n(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —\nthe title reads once. Derived life-events, world-bands, and letterless event pills\nare unchanged (REQ-001 amended for curated-with-letters).\n\nRefs #827')"
```
---
## Task 5: Regression sweep + route view + docs
**Files:**
- Verify: route + cross-year + thema specs
- Modify: `.specify/rtm.md`
- [ ] **Step 1: Run the affected specs (client).**
```
npx vitest run \
src/lib/timeline/BucketHeaderChip.svelte.spec.ts src/lib/timeline/LetterCard.svelte.spec.ts \
src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts \
src/lib/timeline/GroupingControl.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts \
src/lib/timeline/TimelineView.svelte.spec.ts src/routes/zeitstrahl/page.svelte.spec.ts --project=client
```
Expected: all PASS. Fix any cross-year/thema spec that assumed the old header (it should now find the card; thema card header is still the `BucketHeaderChip`).
- [ ] **Step 2: Run the server specs.** `npx vitest run src/lib/timeline/timelineGrouping.spec.ts src/lib/timeline/timeline-no-raw-html.spec.ts src/lib/messages.spec.ts --project=server` → PASS. If `messages.spec.ts` parity fails, it's the two new keys — they must be in de/en/es.
- [ ] **Step 3: Type-check the changed files.** `npm run check 2>&1 | grep -E "LetterBucket|YearBand|timelineGrouping"` → no ERROR lines (baseline noise elsewhere is fine).
- [ ] **Step 4: Update the RTM.** In `.specify/rtm.md`, edit REQ-014 (event-clustered letters live inside a contained card whose header is the same-year event; first-5 + show-more) and REQ-020 (clusters are contained cards with a 5-letter preview + show-more; the leftover bin is a collapsed drawer; the sparkline is no longer used in grouped mode), citing the new tests.
- [ ] **Step 5: Commit.**
```bash
cd .. && git add .specify/rtm.md
git diff --cached --stat
git commit -m "$(printf 'docs(rtm): trace the grouped-view contained-card layout (#827)\n\nRefs #827')"
```
- [ ] **Step 6: Push.** `git push origin feat/issue-827-zeitstrahl-grouping`
---
## Self-Review notes (author)
- **Spec coverage:** contained card (Task 3) ✓; first-5 + show-more (Task 1) ✓; collapsed drawer (Task 2) ✓; same-year event → card header / no duplicate (Task 4) ✓; derived/world unchanged (Task 4 keeps plain rows) ✓; thema chip header reused (existing, verified Task 5) ✓; cross-year text header (existing `{t:'bucket'}` path, verified Task 5) ✓; sparkline dropped from grouped mode (Task 1) ✓.
- **Naming consistency:** `CLUSTER_PREVIEW` (Task 1) used in Tasks 12; testids `bucket-show-more` / `bucket-reveal` / `bucket-event-header` / `event-edit` consistent across tasks; `Row` variant `eventcard` defined and consumed in Task 4.
- **REQ-001 amendment** is intentional and documented in the spec; Task 4 Step 5 flags fixing any stale identity assertion.

View File

@@ -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.

View File

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

View File

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

View File

@@ -1050,6 +1050,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",

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import BucketHeaderChip from './BucketHeaderChip.svelte';
import { entryKey } from './entryKey';
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
* unit (#827). The axis-fixed world-band layer is rendered elsewhere — this is only the
* loose-letter bundling.
*
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
* tag chip is suppressed (REQ-004/015/017).
* - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date,
* provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign,
* REQ-001/014). A cross-year cluster keeps a plain text header. The standalone "Weitere Briefe"
* / "Ohne Thema" fallback keeps its label and a neutral dashed rail (REQ-006/007).
*
* A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest
* instead of flooding the timeline with every card (#827 redesign).
*/
let {
bucket,
mode,
// `year` is the band's year — accepted for the cross-year label card seam (#827) but no
// longer consumed here now the in-bucket month-density strip is gone (the year frames the
// time from the band heading). Kept in the prop contract for callers/tests.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
year = 0,
nested = false,
event = undefined,
canWrite = false
}: {
bucket: LetterBucket;
mode: 'event' | 'thema';
year?: number;
nested?: boolean;
/** The same-year curated event whose letters this card holds — renders as the header. */
event?: TimelineEntryDTO;
canWrite?: boolean;
} = $props();
const count = $derived(bucket.letters.length);
const fallbackLabel = $derived(
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
);
// Event-as-header (#827 redesign): a same-year curated event renders as this card's header,
// mirroring EventPill — glyph + title + date · provenance + an edit pencil for a curator. The
// title is never repeated as a separate floating pill.
const accent = $derived(event ? getAccentConfig(event) : null);
const eventDateLabel = $derived(
event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null
);
const provenance = $derived(
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster,
// neutral for the fallback (and for a colourless/unknown tag token).
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
const isEventCluster = $derived(nested || bucket.kind === 'event');
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
// First-5 preview + show-more (#827 redesign): a large cluster stays readable instead of
// dumping every card into the timeline.
let expanded = $state(false);
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
// The catch-all "Weitere Briefe" / "Ohne Thema" bin is a junk drawer: render it count-only
// behind a reveal control so it never floods the timeline; every other cluster starts open
// (#827 redesign). The view re-creates a bucket per `{#each}` key, so the initial capture is
// the right lifetime — `revealed` belongs to this bucket instance.
const isDrawer = $derived(bucket.kind === 'fallback');
// svelte-ignore state_referenced_locally
let revealed = $state(bucket.kind !== 'fallback');
</script>
<section
class="my-3 overflow-hidden rounded-md border border-l-2 border-line bg-surface shadow-sm"
class:border-l-brand-mint={isEventCluster}
class:border-dashed={isDrawer}
style={railStyle}
data-testid="letter-bucket"
data-bucket-kind={bucket.kind}
>
{#if !nested}
{#if event && accent}
<!-- A same-year curated event IS the card header — its title reads once here, never
also as a floating pill (#827 redesign, REQ-001/014). Glyph is aria-hidden with an
sr-only label sibling (REQ-018); the edit pencil mirrors EventPill's gate. -->
<header
data-testid="bucket-event-header"
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {accent.accent ===
'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<span aria-hidden="true">{accent.glyph}</span>
<span class="sr-only">{accent.label}</span>
</span>
<span class="min-w-0 text-left">
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{event.title}</span
>
<span class="block font-sans text-xs text-ink-3">
{eventSubtitle} <span data-testid="bucket-count">· {count}</span>
</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{event.eventId}/edit"
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}
</header>
{:else}
<header
class="flex items-center gap-2 px-3 py-2"
class:bg-canvas={isEventCluster}
class:border-b={!isDrawer}
class:border-line={!isDrawer}
>
{#if mode === 'thema' && bucket.kind === 'tag'}
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
{:else if mode === 'event' && bucket.kind === 'event'}
<span class="font-serif text-sm font-bold text-ink">
<span aria-hidden="true"></span>
{bucket.title}
</span>
{:else}
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
{/if}
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
</header>
{/if}
{/if}
<div class="px-3 py-2">
{#if !revealed}
<button
type="button"
data-testid="bucket-reveal"
onclick={() => (revealed = true)}
style="display: inline-flex; align-items: center; min-height: 44px"
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
</button>
{:else}
<ul class="space-y-1.5">
{#each visible as letter (entryKey(letter))}
<li>
<LetterCard
entry={letter}
variant={cardVariant}
suppressTagChip={mode === 'thema'}
compact={true}
/>
</li>
{/each}
</ul>
{#if hiddenCount > 0}
<button
type="button"
data-testid="bucket-show-more"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
style="display: inline-flex; align-items: center; min-height: 44px"
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{expanded
? m.timeline_bucket_show_less()
: m.timeline_bucket_show_more({ count: hiddenCount })}
</button>
{/if}
{/if}
</div>
</section>

View File

@@ -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();
});
});

View File

@@ -12,10 +12,30 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* precision-aware date chip, linking to the document. Names/titles are
* OCR/import-derived — rendered via default `{...}` escaping with
* `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
*
* In Ereignis mode the card sits inside an event cluster and renders as the
* `.lcard.ev` variant (#827, REQ-014). In Thema mode the per-letter tag chip is
* suppressed inside its own root-tag bucket, where the bucket header already
* carries the topic (`suppressTagChip`, REQ-017).
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
let {
entry,
variant = 'plain',
suppressTagChip = false,
compact = false
}: {
entry: TimelineEntryDTO;
variant?: 'plain' | 'event';
suppressTagChip?: boolean;
compact?: boolean;
} = $props();
const isEventVariant = $derived(variant === 'event');
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Inside a per-year bucket the year frames the time, and these archive titles already
// embed the date — so the compact in-bucket card drops the redundant date chip when a
// title is present, halving the row height and killing the duplicate date (#827).
const showDate = $derived(!compact || !entry.title);
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
@@ -28,28 +48,37 @@ const receiver = $derived(
<a
href="/documents/{entry.documentId}"
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
class="lcard rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
class:py-2={!compact}
class:py-1={compact}
class:ev={isEventVariant}
class:compact={compact}
>
{#if entry.title}
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
interpolated into the escaped user title; the title keeps its own
pre-line span for multi-line OCR text (REQ-008/016/021). -->
<span class="font-serif text-sm font-bold break-words text-ink">
<span
class="font-serif font-bold break-words text-ink"
class:text-sm={!compact}
class:text-xs={compact}
>
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
<span class="whitespace-pre-line">{entry.title}</span>
</span>
{/if}
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
<span class="font-sans text-xs break-words text-ink-3" class:mt-0.5={!compact}>
<span class="font-serif whitespace-pre-line">{sender}</span>
<span aria-hidden="true"></span>
<span class="font-serif whitespace-pre-line">{receiver}</span>
{#if dateLabel}
{#if dateLabel && showDate}
<span data-testid="letter-date"> · {dateLabel}</span>
{/if}
</span>
{#if entry.rootTagName}
{#if entry.rootTagName && !suppressTagChip}
<!-- The primary root-tag chip sits on its own line beneath the meta line
(#835 §3); absent when the letter has no tag (REQ-005). -->
(#835 §3); absent when the letter has no tag (REQ-005), and suppressed in
Thema mode inside its own root-tag bucket where the header conveys it (REQ-017). -->
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
{/if}
</a>

View File

@@ -127,3 +127,46 @@ describe('LetterCard', () => {
expect(chip?.textContent).toContain('Familie');
});
});
describe('LetterCard — grouping variants (#827, REQ-014/017)', () => {
it('carries the .lcard.ev class in the event variant (REQ-014)', () => {
render(LetterCard, { entry: makeEntry(), variant: 'event' });
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
});
it('is a plain card with no .ev marker by default (REQ-014)', () => {
render(LetterCard, { entry: makeEntry() });
expect(document.querySelector('a.ev')).toBeNull();
});
it('suppresses the per-letter tag chip when asked, even with a root tag (REQ-017)', () => {
render(LetterCard, {
entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }),
suppressTagChip: true
});
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
});
it('still shows the per-letter tag chip when not suppressed — Datum/Ereignis (REQ-017)', () => {
render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) });
expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull();
});
it('drops the redundant date line in the compact variant when a title is present (#827)', () => {
// Inside a per-year bucket the year already frames the time, and these archive
// titles embed the date — so the compact in-bucket card omits the date chip.
render(LetterCard, { entry: makeEntry({ title: 'H-0023 6. Juli 1916' }), compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).toBeNull();
expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown
});
it('keeps the date in the compact variant when the letter has no title (#827)', () => {
render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
});
it('renders the compact variant on a single tighter row (#827)', () => {
render(LetterCard, { entry: makeEntry(), compact: true });
expect(document.querySelector('a.lcard.compact')).not.toBeNull();
});
});

View File

@@ -6,6 +6,7 @@ import LetterCard from './LetterCard.svelte';
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import { entryKey } from './entryKey';
import { buildEventLookup, type GroupingMode } from './timelineGrouping';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
@@ -18,12 +19,28 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
* for the per-person rail (issue #10) and is undefined here; it is not passed to
* leaf cards (REQ-025). Owns no <main> — the layout does.
*
* `groupingMode` (#827) flows down to each YearBand to re-bundle its loose letters;
* the event lookup — the curated events present in this (already layer-filtered)
* view — is resolved once here so Ereignis clusters never reference a filtered-out
* event (filter-then-group, REQ-019). The undated bucket renders unchanged in every
* mode (its letters have no year, so the per-year bucketing does not apply).
*/
let {
timeline,
personId = undefined,
canWrite = false
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
canWrite = false,
groupingMode = 'date'
}: {
timeline: TimelineDTO;
personId?: string;
canWrite?: boolean;
groupingMode?: GroupingMode;
} = $props();
const eventLookup = $derived(
groupingMode === 'date' ? new Map<string, string>() : buildEventLookup(timeline)
);
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
@@ -54,7 +71,12 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
<li>
{#if row.t === 'band'}
<YearBand year={row.year} canWrite={canWrite} />
<YearBand
year={row.year}
canWrite={canWrite}
groupingMode={groupingMode}
eventLookup={eventLookup}
/>
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}

View File

@@ -3,8 +3,14 @@ import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import LetterBucket from './LetterBucket.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import {
bucketLetters,
type GroupingMode,
type LetterBucket as LetterBucketModel
} from './timelineGrouping';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
@@ -15,19 +21,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<string, string>()
}: {
year: TimelineYearDTO;
canWrite?: boolean;
groupingMode?: GroupingMode;
eventLookup?: Map<string, string>;
} = $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<Row[]>(() => {
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<Row[]>(() => {
}
return out;
});
function rowKey(row: Row): string {
if (row.t === 'strip') return `strip-${year.year}`;
if (row.t === 'bucket') return row.bucket.key;
return entryKey(row.entry);
}
</script>
<section class="py-2">
@@ -56,18 +129,28 @@ const rows = $derived.by<Row[]>(() => {
</h2>
<div class="mt-3 space-y-3">
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
{#each rows as row (rowKey(row))}
{#if row.t === 'event'}
{#if row.entry.type === 'HISTORICAL'}
<WorldBand entry={row.entry} canWrite={canWrite} />
{:else}
<EventPill entry={row.entry} canWrite={canWrite} />
{/if}
{:else if row.t === 'eventcard'}
<LetterBucket
bucket={row.bucket}
mode="event"
year={year.year}
event={row.entry}
canWrite={canWrite}
/>
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
<LetterCard entry={row.entry} />
</div>
{:else if row.t === 'bucket'}
<LetterBucket bucket={row.bucket} mode={bucketMode} year={year.year} nested={row.nested} />
{:else}
<YearLetterStrip letters={letters} year={year.year} />
{/if}

View File

@@ -165,3 +165,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');
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const timelineDir = dirname(fileURLToPath(import.meta.url));
/**
* REQ-009 / CWE-79: the regroup touches every component under lib/timeline (the reused TagChip,
* the .lcard.ev card, and the new tinted bucket-header chip). Curator/import-derived text must
* always render through Svelte's default `{...}` escaping — never `{@html}`. This grep gate fails
* loudly the moment any timeline component reaches for the raw-HTML directive.
*/
describe('lib/timeline never uses {@html} (REQ-009)', () => {
it('no timeline component contains the raw-HTML directive', () => {
const components = readdirSync(timelineDir).filter((file) => file.endsWith('.svelte'));
expect(components.length).toBeGreaterThan(0);
const offenders = components.filter((file) =>
readFileSync(join(timelineDir, file), 'utf8').includes('{@html')
);
expect(offenders).toEqual([]);
});
});

View File

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

View File

@@ -0,0 +1,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<string, string> {
const lookup = new Map<string, string>();
const collect = (entries: TimelineEntryDTO[]) => {
for (const entry of entries) {
if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? '');
}
};
for (const band of timeline.years) collect(band.entries);
collect(timeline.undated);
return lookup;
}
/**
* True when the timeline still holds at least one loose letter. Drives the grouping control's
* enabled state: with the Letters layer filtered off there is nothing to regroup (REQ-018).
*/
export function hasLooseLetters(timeline: TimelineDTO): boolean {
const holdsLetter = (entries: TimelineEntryDTO[]) => entries.some((e) => e.kind === 'LETTER');
return timeline.years.some((band) => holdsLetter(band.entries)) || holdsLetter(timeline.undated);
}
/**
* Buckets one year's loose letters for Ereignis/Thema mode. The caller passes only that year's
* `LETTER` entries; events stay on the axis untouched (REQ-001). Buckets keep first-seen order and
* the fallback bucket, if any, always sorts last.
*
* - `event`: cluster under `linkedEventId` when it is set AND survives in `eventLookup`; otherwise
* the fallback "Weitere Briefe" bucket (REQ-003/006/019).
* - `thema`: bucket under `rootTagId` (header = `rootTagName`, tint = `rootTagColor`); an untagged
* letter goes to the fallback "Ohne Thema" bucket (REQ-004/007). A letter carries exactly one
* `rootTagId`, so it lands in exactly one bucket (REQ-008).
*/
export function bucketLetters(
letters: TimelineEntryDTO[],
mode: Exclude<GroupingMode, 'date'>,
eventLookup: Map<string, string>
): LetterBucket[] {
const byKey = new Map<string, LetterBucket>();
let fallback: LetterBucket | null = null;
const fallbackBucket = (): LetterBucket => {
if (!fallback) fallback = { key: '__fallback__', kind: 'fallback', color: null, letters: [] };
return fallback;
};
const namedBucket = (id: string, build: () => LetterBucket): LetterBucket => {
let bucket = byKey.get(id);
if (!bucket) {
bucket = build();
byKey.set(id, bucket);
}
return bucket;
};
for (const letter of letters) {
if (mode === 'event') {
const id = letter.linkedEventId;
if (id && eventLookup.has(id)) {
namedBucket(id, () => ({
key: `event:${id}`,
kind: 'event',
title: eventLookup.get(id),
color: null,
letters: []
})).letters.push(letter);
} else {
fallbackBucket().letters.push(letter);
}
} else {
const id = letter.rootTagId;
if (id) {
namedBucket(id, () => ({
key: `tag:${id}`,
kind: 'tag',
title: letter.rootTagName ?? '',
color: letter.rootTagColor ?? null,
letters: []
})).letters.push(letter);
} else {
fallbackBucket().letters.push(letter);
}
}
}
const buckets = [...byKey.values()];
if (fallback) buckets.push(fallback);
return buckets;
}

View File

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

View File

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