Compare commits

..

25 Commits

Author SHA1 Message Date
Marcel
be4bf8edc0 docs(rtm): trace the grouped-view contained-card layout (#827)
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 7m39s
CI / OCR Service Tests (pull_request) Successful in 47s
CI / Backend Unit Tests (pull_request) Failing after 15m5s
CI / fail2ban Regex (pull_request) Failing after 2m6s
CI / Semgrep Security Scan (pull_request) Successful in 44s
CI / Compose Bucket Idempotency (pull_request) Successful in 2m8s
SDD Gate / RTM Check (pull_request) Successful in 33s
SDD Gate / Contract Validate (pull_request) Successful in 43s
SDD Gate / Constitution Impact (pull_request) Successful in 34s
Refs #827
2026-06-15 16:35:03 +02:00
Marcel
70794616d2 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 16:35:03 +02:00
Marcel
e100213760 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 16:35:03 +02:00
Marcel
bea9acfe63 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 16:35:03 +02:00
Marcel
5a8bee3970 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 16:35:03 +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
40 changed files with 1968 additions and 1102 deletions

View File

@@ -2,7 +2,7 @@
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec > 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 > 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 > 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`. > to end, and any orphan (a requirement with no test) is visible on `main`.
@@ -24,30 +24,31 @@
## Matrix ## Matrix
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status | | 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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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. --> <!-- 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-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-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-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-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-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-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 | | 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-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-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-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-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-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 | | 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,18 +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-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-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-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 | `/zeitstrahl` renders a single chronological timeline with no grouping-mode control (no toggle/Thema/drawer) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/TimelineView.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`; absence of `GroupingControl`/`LetterBucket`/`BucketHeaderChip` (never ported) | 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 | curated event with ≥1 same-year linked letter → contained card (event header + glyph/title/date·provenance/edit + compact letter cards) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `EventCluster.svelte.spec.ts#renders a data-testid event-card with the event title once`, `#shows the event-edit link for a curator`, `#renders its letters as compact a.lcard.ev cards`; `YearBand.svelte.spec.ts#renders a curated event with a same-year linked letter as one event-card, title once, no separate pill` | 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 | event card body > 5 letters → first 5 + keyboard-operable show-more/less toggle (aria-expanded, ≥44px) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/eventClustering.ts` (`CLUSTER_PREVIEW=5`) | `EventCluster.svelte.spec.ts#shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5`, `#renders no show-more toggle when the cluster holds 5 or fewer letters` | 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 | linked letters in a year other than the event's band → labeled ✉ text-header card (no pill, no edit link) per such year | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `EventCluster.svelte.spec.ts#renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given`; `YearBand.svelte.spec.ts#renders a cross-year cluster (event not in this band) as a ✉ text-header card holding it` | 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 | event/derived/world-band with no linked letters → existing plain pill/world-band (unchanged) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `YearBand.svelte.spec.ts#renders a curated event with NO linked letters as a plain EventPill, no card` | 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-006 | letter linked to no curated event → loose chronological letter (alternating; density strip past 12) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#keeps a letter with no linkedEventId loose`; `YearBand.svelte.spec.ts#renders loose (unlinked) letters in a sparse year as alternating .letter-row, no card` | 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-007 | loose-letter layout + density strip count ONLY non-event-linked letters; clustered letter never also loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#places each letter in exactly one place`; `YearBand.svelte.spec.ts#counts only loose letters in the density strip; event letters stay in the card` | 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-008 | letter whose only linking event is filtered out (absent from lookup) → loose, never re-introduces the event (filter-then-cluster) | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`splitYearLetters` filters via `eventLookup`) | `eventClustering.spec.ts#keeps a letter whose linkedEventId is absent from the lookup loose (filter-then-cluster, REQ-008)` | 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-009 | `TimelineEntryDTO` carries nullable `linkedEventId` for LETTER entries, one batched pass, no new column/migration, not @Schema(REQUIRED); a multi-event letter links deterministically (earliest date, then id) | #850 | inline-event-clustering | `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`, `#letter_in_no_curated_event_has_null_linkedEventId`, `#multi_event_letter_links_deterministically_to_the_earliest_event` | 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-010 | event/letter/sender/receiver text via Svelte `{...}` escaping; no `{@html}` anywhere in `lib/timeline/` (grep gate, CWE-79) | #850 | inline-event-clustering | `frontend/src/lib/timeline/*.svelte` | `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive`; `EventCluster.svelte.spec.ts#renders an HTML-bearing event title verbatim as text, never as markup` | 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-011 | wrapping header keeps #842 add-event CTA + #780 filter trigger; meta-line drops the grouping segment | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`, `#drops the letters segment instead of showing "0 Briefe"` (no "Gruppierung") | 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-012 | show-more/less labels are new Paraglide keys in de/en/es; unused #827 grouping/Thema keys removed | #850 | inline-event-clustering | `frontend/messages/{de,en,es}.json`, `frontend/src/lib/timeline/EventCluster.svelte` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales` (now asserts `timeline_bucket_show_more`/`_less`; `timeline_grouping_date` removed) | 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-013 | `GET /api/timeline` failure → existing localized error state via `getErrorMessage(code)` (unchanged #779) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` (unchanged error path) | covered by #779 `zeitstrahl` error-state tests (regression — no change) | 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-014 | HISTORICAL curated event with ≥1 linked letter keeps its full-width WorldBand — never clusters into a card (preserves #779 REQ-009); its letters stay loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`buildEventLookup` excludes HISTORICAL) | `eventClustering.spec.ts#excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)`; `TimelineView.svelte.spec.ts#keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)` | 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-015 | a cross-year ✉ card is placed at its earliest linked letter's chronological position in the band — never appended after later-dated loose letters | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)` | 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

@@ -30,7 +30,7 @@ import java.util.UUID;
* *
* <p><b>Letter→event link ({@code linkedEventId}):</b> for a {@link Kind#LETTER} entry, the id of * <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 * 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 (#850). Computed on read from the * {@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 * 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 * entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* type stays optional. * type stays optional.

View File

@@ -80,20 +80,18 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers // Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation()); Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// Fetch curated events once; the events that survive the filter below feed both the // Fetch curated events once — reused for both the event entries below and the
// event entries and the batched letter→event link pass (resolveLetterEventLinks), so the // batched letter→event link resolution (resolveLetterEventLinks), so the
// membership pass costs no extra query and touches only on-screen events. REQ-009. // membership pass costs no extra query. REQ-005.
List<TimelineEvent> allEvents = eventRepository.findAll(); List<TimelineEvent> allEvents = eventRepository.findAll();
// ── curated events ─────────────────────────────────────────────────── // ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>(); List<TimelineEntryDTO> entries = new ArrayList<>();
List<TimelineEvent> filteredEvents = new ArrayList<>();
for (TimelineEvent ev : allEvents) { for (TimelineEvent ev : allEvents) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue; if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue; if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue; if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue; if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
filteredEvents.add(ev);
entries.add(mapEvent(ev)); entries.add(mapEvent(ev));
} }
@@ -114,7 +112,7 @@ public class TimelineService {
letters.add(doc); letters.add(doc);
} }
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters); Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, filteredEvents); Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
for (Document doc : letters) { for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId, eventByDocId)); entries.add(mapDocument(doc, rootByDocId, eventByDocId));
} }
@@ -268,32 +266,20 @@ public class TimelineService {
/** /**
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the * 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-009). A single doc→event map is * 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} * 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 * carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
* event, the earliest-dated event wins (then lowest {@code eventId}); the pass runs over a * event, the first by repository iteration order wins ({@code putIfAbsent}). The map is built
* stably-ordered copy so the link is a deterministic property of the data, not a coin-flip on * from <em>all</em> events (not just the year/type-filtered ones) so the link is a stable
* the undefined repository iteration order ({@code putIfAbsent} keeps the first = winner). The * property of the data; the frontend's filter-then-group decides whether the linked event is
* map is built only over the events that survived the timeline filter, so the lazy * actually on screen (#827). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
* {@code documents} collection is hydrated for on-screen events alone (finding #10); a letter
* whose only linking event was filtered out links to nothing, matching the frontend's
* filter-then-cluster (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
*/ */
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) { private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet()); Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
if (letterDocIds.isEmpty()) return Map.of(); if (letterDocIds.isEmpty()) return Map.of();
// Stable order so a multi-event letter links deterministically: earliest event date
// (undated last), then lowest id. putIfAbsent below then keeps the winner (REQ-009).
List<TimelineEvent> ordered = events.stream()
.sorted(Comparator
.comparing(TimelineEvent::getEventDate,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(TimelineEvent::getId))
.toList();
Map<UUID, UUID> eventByDocId = new HashMap<>(); Map<UUID, UUID> eventByDocId = new HashMap<>();
for (TimelineEvent ev : ordered) { for (TimelineEvent ev : events) {
Set<Document> linkedDocs = ev.getDocuments(); Set<Document> linkedDocs = ev.getDocuments();
if (linkedDocs == null) continue; if (linkedDocs == null) continue;
for (Document linked : linkedDocs) { for (Document linked : linkedDocs) {

View File

@@ -511,11 +511,11 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList()); verify(tagService, times(1)).resolveRootTags(anyList());
} }
// ─── letter→event link (#850, REQ-009) ─────────────────────────────────── // ─── letter→event link (#827, REQ-005/006) ───────────────────────────────
@Test @Test
void letter_in_a_curated_events_documents_carries_that_events_id() { void letter_in_a_curated_events_documents_carries_that_events_id() {
// REQ-009: linkedEventId = the curated event whose documents set contains the letter. // REQ-005: linkedEventId = the curated event whose documents set contains the letter.
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH); Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
UUID eventId = UUID.randomUUID(); UUID eventId = UUID.randomUUID();
TimelineEvent event = TimelineEvent.builder().id(eventId) TimelineEvent event = TimelineEvent.builder().id(eventId)
@@ -533,8 +533,8 @@ class TimelineServiceTest {
@Test @Test
void letter_in_no_curated_event_has_null_linkedEventId() { void letter_in_no_curated_event_has_null_linkedEventId() {
// REQ-009: a letter referenced by no curated event → linkedEventId null; the frontend // REQ-006: a letter referenced by no curated event → linkedEventId null (frontend falls
// then renders it as a loose chronological letter (REQ-006). // back to the per-year "Weitere Briefe" bucket).
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH); Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID()) TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
.title("Anderes Ereignis").type(EventType.PERSONAL) .title("Anderes Ereignis").type(EventType.PERSONAL)
@@ -549,68 +549,6 @@ class TimelineServiceTest {
assertThat(entry.linkedEventId()).isNull(); assertThat(entry.linkedEventId()).isNull();
} }
@Test
void multi_event_letter_links_deterministically_to_the_earliest_event() {
// REQ-009: a document referenced by >1 curated event links to the earliest-dated event
// (then lowest id), independent of repository iteration order — not a coin-flip on
// findAll()'s undefined order.
Document shared = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
TimelineEvent earlier = TimelineEvent.builder()
.id(UUID.fromString("00000000-0000-0000-0000-000000000001"))
.title("Frühes Ereignis").type(EventType.PERSONAL)
.eventDate(LocalDate.of(1913, 3, 1)).precision(DatePrecision.MONTH)
.documents(new HashSet<>(Set.of(shared)))
.build();
TimelineEvent later = TimelineEvent.builder()
.id(UUID.fromString("00000000-0000-0000-0000-000000000002"))
.title("Spätes Ereignis").type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 3, 1)).precision(DatePrecision.MONTH)
.documents(new HashSet<>(Set.of(shared)))
.build();
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(shared));
// `later` first in iteration: a naive putIfAbsent would wrongly pick it.
when(eventRepository.findAll()).thenReturn(List.of(later, earlier));
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
.isEqualTo(earlier.getId());
// Reversed order yields the same winner — the link is order-independent.
when(eventRepository.findAll()).thenReturn(List.of(earlier, later));
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
.isEqualTo(earlier.getId());
}
@Test
void letter_linked_only_to_a_filtered_out_event_has_null_linkedEventId() {
// finding #10: the link pass runs over the events that survived the filter, not all of
// them. A letter whose only linking event is excluded by the active filter links to
// nothing (the frontend would drop it loose anyway) — and the lazy `documents` collection
// is never hydrated for events that are off-screen.
Document letterDoc = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
TimelineEvent worldEvent = TimelineEvent.builder().id(UUID.randomUUID())
.title("Somme").type(EventType.HISTORICAL)
.documents(new HashSet<>(Set.of(letterDoc)))
.build();
when(eventRepository.findAll()).thenReturn(List.of(worldEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
// Filter to PERSONAL only → the HISTORICAL event is filtered out of the view.
TimelineEntryDTO entry = theLetter(timelineService.assemble(
new TimelineFilter(null, null, EventType.PERSONAL, null, null)));
assertThat(entry.linkedEventId()).isNull();
}
private static TimelineEntryDTO theLetter(TimelineDTO result) {
return java.util.stream.Stream.concat(
result.years().stream().flatMap(y -> y.entries().stream()),
result.undated().stream())
.filter(e -> e.kind() == Kind.LETTER)
.findFirst().orElseThrow();
}
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) { private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1); assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0); return result.years().get(0).entries().get(0);

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,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 │ │ ├── relationship/ # Relationship form + chip components
│ │ └── genealogy/ # Stammbaum (family tree) components │ │ └── genealogy/ # Stammbaum (family tree) components
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker │ ├── 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 │ ├── geschichte/ # Geschichte (story) domain: editor + card
│ ├── notification/ # Notification bell + dropdown + store │ ├── notification/ # Notification bell + dropdown + store
│ ├── activity/ # Activity feed (Chronik) components │ ├── 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

@@ -1049,12 +1049,25 @@
"timeline_derived_birth": "Geburt", "timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod", "timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat", "timeline_derived_marriage": "Heirat",
"timeline_provenance_derived": "abgeleitet", "timeline_grouping_date": "Gruppierung: Datum",
"timeline_provenance_curated": "kuratiert", "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_more": "+ {count} weitere Briefe anzeigen",
"timeline_bucket_show_less": "Weniger anzeigen", "timeline_bucket_show_less": "Weniger anzeigen",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_letter_glyph_label": "Brief", "timeline_letter_glyph_label": "Brief",
"timeline_cluster_letter_count": "{count} Briefe",
"timeline_tag_chip_label": "Thema", "timeline_tag_chip_label": "Thema",
"timeline_layer_historical_suffix": "historisch", "timeline_layer_historical_suffix": "historisch",
"timeline_strip_density_caption": "Monats-Dichte", "timeline_strip_density_caption": "Monats-Dichte",

View File

@@ -1049,12 +1049,25 @@
"timeline_derived_birth": "Birth", "timeline_derived_birth": "Birth",
"timeline_derived_death": "Death", "timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage", "timeline_derived_marriage": "Marriage",
"timeline_provenance_derived": "derived", "timeline_grouping_date": "Grouping: Date",
"timeline_provenance_curated": "curated", "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_more": "+ {count} more letters",
"timeline_bucket_show_less": "Show fewer", "timeline_bucket_show_less": "Show fewer",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_letter_glyph_label": "Letter", "timeline_letter_glyph_label": "Letter",
"timeline_cluster_letter_count": "{count} letters",
"timeline_tag_chip_label": "Topic", "timeline_tag_chip_label": "Topic",
"timeline_layer_historical_suffix": "historical", "timeline_layer_historical_suffix": "historical",
"timeline_strip_density_caption": "Monthly density", "timeline_strip_density_caption": "Monthly density",

View File

@@ -1049,12 +1049,25 @@
"timeline_derived_birth": "Nacimiento", "timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento", "timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio", "timeline_derived_marriage": "Matrimonio",
"timeline_provenance_derived": "derivado", "timeline_grouping_date": "Agrupación: Fecha",
"timeline_provenance_curated": "curado", "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_more": "+ {count} cartas más",
"timeline_bucket_show_less": "Mostrar menos", "timeline_bucket_show_less": "Mostrar menos",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_letter_glyph_label": "Carta", "timeline_letter_glyph_label": "Carta",
"timeline_cluster_letter_count": "{count} cartas",
"timeline_tag_chip_label": "Tema", "timeline_tag_chip_label": "Tema",
"timeline_layer_historical_suffix": "histórico", "timeline_layer_historical_suffix": "histórico",
"timeline_strip_density_caption": "Densidad mensual", "timeline_strip_density_caption": "Densidad mensual",

View File

@@ -74,10 +74,9 @@ describe('message key parity', () => {
// every locale so no surface ever falls back to a missing translation. // every locale so no surface ever falls back to a missing translation.
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => { it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
const requiredKeys = [ const requiredKeys = [
'timeline_grouping_date',
'timeline_provenance_derived', 'timeline_provenance_derived',
'timeline_provenance_curated', 'timeline_provenance_curated',
'timeline_bucket_show_more',
'timeline_bucket_show_less',
'timeline_letter_glyph_label', 'timeline_letter_glyph_label',
'timeline_layer_historical_suffix', 'timeline_layer_historical_suffix',
'timeline_strip_density_caption', 'timeline_strip_density_caption',
@@ -100,14 +99,6 @@ describe('message key parity', () => {
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' }); expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
}); });
// #850 finding #6: the event-card letter count carries an sr-only "{count} Briefe" so the
// bare "· 2" never announces to a screen reader without context.
it('zeitstrahl cluster letter-count key is present in all locales (#850 finding #6)', () => {
expect(de).toHaveProperty('timeline_cluster_letter_count');
expect(en).toHaveProperty('timeline_cluster_letter_count');
expect(es).toHaveProperty('timeline_cluster_letter_count');
});
// #780 REQ-010: the layer-filter strings are Paraglide keys in every locale. // #780 REQ-010: the layer-filter strings are Paraglide keys in every locale.
// timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count}, // timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count},
// ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)". // ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)".
@@ -142,4 +133,33 @@ describe('message key parity', () => {
expect(es, `missing key in es: ${key}`).toHaveProperty(key); 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

@@ -1,104 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import GlyphLabel from './GlyphLabel.svelte';
import EventHeader from './EventHeader.svelte';
import { entryKey } from './entryKey';
import { CLUSTER_PREVIEW } from './eventClustering';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A curated event with linked letters, rendered as one contained card: the event IS the card's
* header (so its title reads once — never also as a floating pill, #850 REQ-002), and its letters
* sit inside as compact `.lcard.ev` cards.
*
* - Same-year event (`event` given): the shared EventHeader carries the accent glyph + sr-only
* label, the title, a `{date} · {kuratiert|abgeleitet}` subtitle, the letter count, and — for a
* curator on a curated event — an edit link to `/zeitstrahl/events/{eventId}/edit` (REQ-002).
* - Cross-year (`title` given, no `event`): a plain `✉ {title}` text header, no edit link, no pill
* chrome — it holds that other year's linked letters (REQ-004).
*
* A card shows its first {@link CLUSTER_PREVIEW} letters, then a keyboard-operable show-more/less
* toggle reveals/collapses the rest instead of flooding the timeline (REQ-003).
*/
let {
letters,
event = undefined,
title = '',
canWrite = false
}: {
letters: TimelineEntryDTO[];
/** The same-year curated event whose letters this card holds — renders as the header. */
event?: TimelineEntryDTO;
/** Header label for a cross-year card (no `event`). */
title?: string;
canWrite?: boolean;
} = $props();
const count = $derived(letters.length);
// First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every
// card into the timeline.
let expanded = $state(false);
const visible = $derived(expanded ? letters : letters.slice(0, CLUSTER_PREVIEW));
const hiddenCount = $derived(letters.length - CLUSTER_PREVIEW);
</script>
<section
class="my-3 overflow-hidden rounded-md border border-l-2 border-line border-l-brand-mint bg-surface shadow-sm"
data-testid="event-card"
>
{#if event}
<!-- A same-year curated event IS the card header (the shared EventHeader) — its title reads
once here, never also as a floating pill (REQ-002); the edit pencil uses the single
canEditEvent gate (REQ-010, #850 finding #5). -->
<header
data-testid="event-header"
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
>
<EventHeader entry={event} canWrite={canWrite} count={count} />
</header>
{:else}
<!-- Cross-year card (REQ-004): the same event's letters in another year band get a plain
✉ text header — no pill chrome, no edit link. -->
<header
data-testid="event-header"
class="flex items-center gap-2 border-b border-line px-3 py-2"
>
<span class="font-serif text-sm font-bold whitespace-pre-line text-ink">
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
{title}
</span>
<span data-testid="event-count" class="font-sans text-xs text-ink-3">
<span aria-hidden="true">· {count}</span>
<span class="sr-only">{m.timeline_cluster_letter_count({ count })}</span>
</span>
</header>
{/if}
<div class="px-3 py-2">
<ul class="space-y-1.5">
{#each visible as letter (entryKey(letter))}
<li>
<LetterCard entry={letter} variant="event" 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}
</div>
</section>

View File

@@ -1,129 +0,0 @@
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 EventCluster from './EventCluster.svelte';
import { makeEntry } from './test-factories';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
afterEach(() => cleanup());
const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const makeEvent = (overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
documentId: undefined,
eventId: EV_ID,
eventDate: '1916-07-06',
precision: 'DAY',
title: 'Ein gewaltiger Stadtbrand',
...overrides
});
const letters = (n: number): TimelineEntryDTO[] =>
Array.from({ length: n }, (_, i) =>
makeEntry({ kind: 'LETTER', documentId: `doc-${i}`, title: `Brief ${i}`, linkedEventId: EV_ID })
);
describe('EventCluster — contained event card (#850)', () => {
it('renders a data-testid event-card with the event title once (REQ-002)', () => {
render(EventCluster, { letters: letters(2), event: makeEvent() });
expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull();
const occurrences = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? [])
.length;
expect(occurrences).toBe(1);
});
it('shows the event-edit link for a curator on a curated event (REQ-002)', () => {
render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: true });
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement;
expect(edit).not.toBeNull();
expect(edit.getAttribute('href')).toBe(`/zeitstrahl/events/${EV_ID}/edit`);
});
it('hides the event-edit link when canWrite is false', () => {
render(EventCluster, { letters: letters(2), event: makeEvent(), canWrite: false });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('hides the event-edit link for a derived event even with canWrite', () => {
render(EventCluster, {
letters: letters(2),
event: makeEvent({ derived: true, eventId: undefined, derivedType: 'BIRTH' }),
canWrite: true
});
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('renders its letters as compact a.lcard.ev cards (REQ-002)', () => {
render(EventCluster, { letters: letters(2), event: makeEvent() });
expect(document.querySelectorAll('a.lcard.ev').length).toBeGreaterThan(0);
});
it('shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5 (REQ-003)', async () => {
render(EventCluster, { letters: letters(8), event: makeEvent() });
expect(document.querySelectorAll('a.lcard').length).toBe(5);
const toggle = document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement;
expect(toggle).not.toBeNull();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
toggle.click();
await tick();
expect(document.querySelectorAll('a.lcard').length).toBe(8);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
toggle.click();
await tick();
expect(document.querySelectorAll('a.lcard').length).toBe(5);
});
it('renders no show-more toggle when the cluster holds 5 or fewer letters', () => {
render(EventCluster, { letters: letters(5), event: makeEvent() });
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
});
it('renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given (REQ-004)', () => {
render(EventCluster, {
letters: letters(2),
title: 'Briefe von der Front',
canWrite: true
});
expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull();
expect(document.body.textContent).toContain('✉');
expect(document.body.textContent).toContain('Briefe von der Front');
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('pairs the cross-year ✉ glyph with an sr-only label so it is not a silent glyph (finding #6)', () => {
render(EventCluster, { letters: letters(2), title: 'Briefe von der Front' });
const header = document.querySelector('[data-testid="event-header"]') as HTMLElement;
const hidden = header.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toContain('✉');
const srOnly = header.querySelector('.sr-only');
expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label());
});
it('gives the letter count an sr-only "{count} Briefe" label so "· 2" is not announced bare (finding #6)', () => {
render(EventCluster, { letters: letters(2), title: 'Briefe von der Front' });
const count = document.querySelector('[data-testid="event-count"]') as HTMLElement;
// the visible "· 2" stays aria-hidden; the sr-only sibling carries the meaning
expect(count.querySelector('[aria-hidden="true"]')?.textContent).toContain('· 2');
expect(count.querySelector('.sr-only')?.textContent).toBe(
m.timeline_cluster_letter_count({ count: 2 })
);
});
it('renders an HTML-bearing event title verbatim as text, never as markup (REQ-010)', () => {
render(EventCluster, {
letters: letters(1),
event: makeEvent({ title: '<img src=x onerror=alert(1)>' })
});
expect(document.querySelector('[data-testid="event-card"] img')).toBeNull();
expect(document.body.textContent).toContain('<img src=x onerror=alert(1)>');
});
});

View File

@@ -1,69 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import GlyphLabel from './GlyphLabel.svelte';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* The shared header for a curated or derived timeline event — the accent glyph circle, the title,
* and the `{date} · {kuratiert|abgeleitet}` subtitle, plus a curator edit pencil gated by the
* single canEditEvent() contract. Rendered by EventPill (inside the floating axis pill) and by
* EventCluster (as a same-year event-card header), so the glyph/title/subtitle markup and the
* security-relevant edit gate live in one place (#850 finding #5). It renders three sibling nodes
* (glyph circle, text block, optional edit pencil) into the parent's flex row — the parent owns
* the wrapper (pill vs card header). An optional letter `count` appends a screen-reader-labeled
* "· {count}" for the event-card case.
*/
let {
entry,
canWrite = false,
count = undefined
}: { entry: TimelineEntryDTO; canWrite?: boolean; count?: number } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Provenance reads off entry.derived: a derived life-event is "abgeleitet", a curated event
// "kuratiert"; the date is an optional prefix so an undated event still reads the provenance.
const provenance = $derived(
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canEditEvent(entry, canWrite));
</script>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent === 'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<GlyphLabel glyph={config.glyph} label={config.label} />
</span>
<span class="min-w-0 text-left">
{#if entry.title}
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{entry.title}</span
>
{/if}
<span class="block font-sans text-xs text-ink-3">
{subtitle}
{#if count !== undefined}
<span data-testid="event-count">
<span aria-hidden="true">· {count}</span>
<span class="sr-only">{m.timeline_cluster_letter_count({ count })}</span>
</span>
{/if}
</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.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}

View File

@@ -1,66 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import EventHeader from './EventHeader.svelte';
import { makeEntry } from './test-factories';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
afterEach(() => cleanup());
const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const curated = (overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: EV_ID,
eventDate: '1916-07-06',
precision: 'DAY',
title: 'Ein gewaltiger Stadtbrand',
documentId: undefined,
...overrides
});
describe('EventHeader', () => {
it('renders the glyph with an sr-only label, the title, and the provenance subtitle', () => {
render(EventHeader, { entry: curated() });
expect(document.querySelector('.sr-only')?.textContent).toBe(m.timeline_layer_family());
expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand');
expect(document.body.textContent).toContain(m.timeline_provenance_curated());
});
it('shows the edit pencil for a writer on a curated event (canEditEvent gate)', () => {
render(EventHeader, { entry: curated(), canWrite: true });
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement;
expect(edit).not.toBeNull();
expect(edit.getAttribute('href')).toBe(`/zeitstrahl/events/${EV_ID}/edit`);
});
it('hides the edit pencil without write, for a derived event, and for a null eventId', () => {
render(EventHeader, { entry: curated(), canWrite: false });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
cleanup();
render(EventHeader, { entry: curated({ derived: true }), canWrite: true });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
cleanup();
render(EventHeader, { entry: curated({ eventId: undefined }), canWrite: true });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('renders a screen-reader-labeled letter count when a count is given', () => {
render(EventHeader, { entry: curated(), count: 3 });
const count = document.querySelector('[data-testid="event-count"]') as HTMLElement;
expect(count.querySelector('[aria-hidden="true"]')?.textContent).toContain('· 3');
expect(count.querySelector('.sr-only')?.textContent).toBe(
m.timeline_cluster_letter_count({ count: 3 })
);
});
it('omits the letter count when no count is given (the pill case)', () => {
render(EventHeader, { entry: curated() });
expect(document.querySelector('[data-testid="event-count"]')).toBeNull();
});
});

View File

@@ -1,21 +1,32 @@
<script lang="ts"> <script lang="ts">
import EventHeader from './EventHeader.svelte'; import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig'; import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/** /**
* Centered axis pill for a derived life-event or a curated PERSONAL event * Centered axis pill for a derived life-event or a curated PERSONAL event
* (REQ-007/008). The pill border keys off the accent (curated = mint, derived = * (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
* navy); its glyph, title, subtitle, and curator edit pencil are the shared * (REQ-018). An edit affordance shows only for a curated event with an eventId
* EventHeader, so the edit gate (canEditEvent) lives in one place — #842 * (never derived, never null — REQ-008) and only for a curator who holds
* REQ-005/007/008, #850 finding #5. The gate is UX only; the real boundary is the * WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The
* #781 route guard + backend permission. * gate is UX only; the real boundary is the #781 route guard + backend permission.
*/ */
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props(); let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
const config = $derived(getAccentConfig(entry)); const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Provenance reads off entry.derived (not the accent): a derived life-event is
// "abgeleitet", a curated PERSONAL event is "kuratiert" (REQ-007).
const provenance = $derived(
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
// Provenance always shows; the date is an optional prefix so an undated event
// still reads "abgeleitet"/"kuratiert" (REQ-007).
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
</script> </script>
<div class="flex justify-center"> <div class="flex justify-center">
@@ -25,6 +36,32 @@ const config = $derived(getAccentConfig(entry));
? 'border-2 border-brand-mint' ? 'border-2 border-brand-mint'
: 'border border-brand-navy'}" : 'border border-brand-navy'}"
> >
<EventHeader entry={entry} canWrite={canWrite} /> <span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent ===
'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<span aria-hidden="true">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
</span>
<span class="text-left">
{#if entry.title}
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{entry.title}</span
>
{/if}
<span class="block font-sans text-xs text-ink-3">{subtitle}</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.eventId}/edit"
class="ml-1 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}
</div> </div>
</div> </div>

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

@@ -11,12 +11,12 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* A single archive letter on the timeline: sender → receiver, title, and a * A single archive letter on the timeline: sender → receiver, title, and a
* precision-aware date chip, linking to the document. Names/titles are * precision-aware date chip, linking to the document. Names/titles are
* OCR/import-derived — rendered via default `{...}` escaping with * OCR/import-derived — rendered via default `{...}` escaping with
* `whitespace-pre-line` for line breaks (REQ-010); never the raw-HTML directive. * `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
* *
* Inside an event cluster the card sits in the contained event card and renders as * In Ereignis mode the card sits inside an event cluster and renders as the
* the `.lcard.ev` `compact` variant (#850, REQ-002): tighter row, and the redundant * `.lcard.ev` variant (#827, REQ-014). In Thema mode the per-letter tag chip is
* date chip is dropped when the title already embeds the date. The per-letter tag * suppressed inside its own root-tag bucket, where the bucket header already
* chip can be suppressed via `suppressTagChip` for callers that already convey it. * carries the topic (`suppressTagChip`, REQ-017).
*/ */
let { let {
entry, entry,
@@ -32,12 +32,10 @@ let {
const isEventVariant = $derived(variant === 'event'); const isEventVariant = $derived(variant === 'event');
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)); const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Inside an event card the band frames the time, so a compact in-card letter drops the // Inside a per-year bucket the year frames the time, and these archive titles already
// redundant date chip — but ONLY when the (free-form OCR) title actually embeds the formatted // embed the date — so the compact in-bucket card drops the redundant date chip when a
// date, e.g. "H-0023 6. Juli 1916". A title without the date keeps its chip, so a letter like // title is present, halving the row height and killing the duplicate date (#827).
// "Brief an Mutter" never loses its month/day (the band frames only the year) — #850, finding #4. const showDate = $derived(!compact || !entry.title);
const titleEmbedsDate = $derived(!!dateLabel && !!entry.title && entry.title.includes(dateLabel));
const showDate = $derived(!compact || !titleEmbedsDate);
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName); const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived( const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
@@ -79,8 +77,8 @@ const receiver = $derived(
</span> </span>
{#if entry.rootTagName && !suppressTagChip} {#if entry.rootTagName && !suppressTagChip}
<!-- The primary root-tag chip sits on its own line beneath the meta line <!-- The primary root-tag chip sits on its own line beneath the meta line
(#835 §3); absent when the letter has no tag (REQ-006), and suppressed when (#835 §3); absent when the letter has no tag (REQ-005), and suppressed in
the caller already conveys the topic (suppressTagChip). --> Thema mode inside its own root-tag bucket where the header conveys it (REQ-017). -->
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} /> <TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
{/if} {/if}
</a> </a>

View File

@@ -128,18 +128,18 @@ describe('LetterCard', () => {
}); });
}); });
describe('LetterCard — event-cluster variants (#850, REQ-002)', () => { describe('LetterCard — grouping variants (#827, REQ-014/017)', () => {
it('carries the .lcard.ev class in the event variant (REQ-002)', () => { it('carries the .lcard.ev class in the event variant (REQ-014)', () => {
render(LetterCard, { entry: makeEntry(), variant: 'event' }); render(LetterCard, { entry: makeEntry(), variant: 'event' });
expect(document.querySelector('a.lcard.ev')).not.toBeNull(); expect(document.querySelector('a.lcard.ev')).not.toBeNull();
}); });
it('is a plain card with no .ev marker by default (REQ-006)', () => { it('is a plain card with no .ev marker by default (REQ-014)', () => {
render(LetterCard, { entry: makeEntry() }); render(LetterCard, { entry: makeEntry() });
expect(document.querySelector('a.ev')).toBeNull(); expect(document.querySelector('a.ev')).toBeNull();
}); });
it('suppresses the per-letter tag chip when asked, even with a root tag', () => { it('suppresses the per-letter tag chip when asked, even with a root tag (REQ-017)', () => {
render(LetterCard, { render(LetterCard, {
entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }), entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }),
suppressTagChip: true suppressTagChip: true
@@ -147,37 +147,25 @@ describe('LetterCard — event-cluster variants (#850, REQ-002)', () => {
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull(); expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
}); });
it('still shows the per-letter tag chip when not suppressed', () => { it('still shows the per-letter tag chip when not suppressed — Datum/Ereignis (REQ-017)', () => {
render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) }); render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) });
expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull(); expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull();
}); });
it('drops the compact date chip only when the title actually embeds the formatted date (#850)', () => { it('drops the redundant date line in the compact variant when a title is present (#827)', () => {
// An archive title like "H-0023 6. Juli 1916" already carries the date, so inside an // Inside a per-year bucket the year already frames the time, and these archive
// event card (where the band frames the time) the redundant chip is dropped. // titles embed the date — so the compact in-bucket card omits the date chip.
const entry = makeEntry({ eventDate: '1916-07-06', precision: 'DAY' }); render(LetterCard, { entry: makeEntry({ title: 'H-0023 6. Juli 1916' }), compact: true });
const dateLabel = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
render(LetterCard, { entry: { ...entry, title: `H-0023 ${dateLabel}` }, compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).toBeNull(); expect(document.querySelector('[data-testid="letter-date"]')).toBeNull();
expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown
}); });
it('keeps the compact date chip when the title does NOT embed the date (#850, finding #4)', () => { it('keeps the date in the compact variant when the letter has no title (#827)', () => {
// Titles are free-form OCR text — a titled letter whose title carries no date must keep
// its month/day, since inside an event card the band frames only the year.
render(LetterCard, {
entry: makeEntry({ eventDate: '1916-07-06', precision: 'DAY', title: 'Brief an Mutter' }),
compact: true
});
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
});
it('keeps the date in the compact variant when the letter has no title (#850)', () => {
render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true }); render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull(); expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
}); });
it('renders the compact variant on a single tighter row (#850)', () => { it('renders the compact variant on a single tighter row (#827)', () => {
render(LetterCard, { entry: makeEntry(), compact: true }); render(LetterCard, { entry: makeEntry(), compact: true });
expect(document.querySelector('a.lcard.compact')).not.toBeNull(); expect(document.querySelector('a.lcard.compact')).not.toBeNull();
}); });

View File

@@ -6,7 +6,7 @@ import LetterCard from './LetterCard.svelte';
import EventPill from './EventPill.svelte'; import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte'; import WorldBand from './WorldBand.svelte';
import { entryKey } from './entryKey'; import { entryKey } from './entryKey';
import { buildEventLookup } from './eventClustering'; import { buildEventLookup, type GroupingMode } from './timelineGrouping';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO']; type TimelineDTO = components['schemas']['TimelineDTO'];
@@ -20,18 +20,27 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
* for the per-person rail (issue #10) and is undefined here; it is not passed to * 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. * leaf cards (REQ-025). Owns no <main> — the layout does.
* *
* The event lookup is built once over the whole (already layer-filtered) timeline * `groupingMode` (#827) flows down to each YearBand to re-bundle its loose letters;
* and threaded to every band so a curated event's letters cluster under it inline * the event lookup — the curated events present in this (already layer-filtered)
* (#850, REQ-002). The undated bucket stays plain (events as pills, letters as * view — is resolved once here so Ereignis clusters never reference a filtered-out
* cards) — out of clustering scope. * 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 { let {
timeline, timeline,
personId = undefined, personId = undefined,
canWrite = false canWrite = false,
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props(); groupingMode = 'date'
}: {
timeline: TimelineDTO;
personId?: string;
canWrite?: boolean;
groupingMode?: GroupingMode;
} = $props();
const eventLookup = $derived(buildEventLookup(timeline)); const eventLookup = $derived(
groupingMode === 'date' ? new Map<string, string>() : buildEventLookup(timeline)
);
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number }; type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
@@ -62,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}`)} {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
<li> <li>
{#if row.t === 'band'} {#if row.t === 'band'}
<YearBand year={row.year} canWrite={canWrite} eventLookup={eventLookup} /> <YearBand
year={row.year}
canWrite={canWrite}
groupingMode={groupingMode}
eventLookup={eventLookup}
/>
{:else} {:else}
<GapSpan from={row.from} to={row.to} /> <GapSpan from={row.from} to={row.to} />
{/if} {/if}

View File

@@ -341,67 +341,4 @@ describe('TimelineView', () => {
expect(hrefs).toContain('/zeitstrahl/events/wb/edit'); expect(hrefs).toContain('/zeitstrahl/events/wb/edit');
expect(hrefs).toContain('/zeitstrahl/events/wu/edit'); expect(hrefs).toContain('/zeitstrahl/events/wu/edit');
}); });
it('builds the event lookup and clusters a curated event + same-year linked letter into an event-card (#850)', () => {
const evId = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';
const event = makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: evId,
eventDate: '1916-07-06',
precision: 'DAY',
title: 'Ein gewaltiger Stadtbrand',
senderName: '',
receiverName: '',
documentId: undefined
});
const letter = makeEntry({
eventDate: '1916-05-10',
documentId: 'doc-linked',
title: 'Brief',
linkedEventId: evId
});
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1916, [event, letter])] })
});
expect(document.querySelector('[data-testid="event-card"]')).not.toBeNull();
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
// the title reads once — the event is the card header, not also a loose pill
const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length;
expect(titles).toBe(1);
});
it('keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)', () => {
const evId = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
const world = makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
eventId: evId,
eventDate: '1916-07-01',
precision: 'DAY',
title: 'Schlacht an der Somme',
senderName: '',
receiverName: '',
documentId: undefined
});
const letter = makeEntry({
eventDate: '1916-05-10',
documentId: 'doc-world-linked',
title: 'Brief von der Front',
linkedEventId: evId
});
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1916, [world, letter])] })
});
// the world event stays a full-width band — no contained event card
expect(document.querySelector('[data-testid="event-card"]')).toBeNull();
expect(document.querySelector('a.lcard.ev')).toBeNull();
// the linked letter renders loose on the spine, not inside a card
expect(document.querySelector('.letter-row')).not.toBeNull();
// and the band keeps its WorldBand "· historisch" register
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
expect(document.body.textContent).toContain('Schlacht an der Somme');
});
}); });

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig, canEditEvent } from './eventCardConfig'; import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel'; import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -26,9 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register // Every WorldBand is a HISTORICAL band, so the visible "historisch" register
// always trails the subtitle as plain text — never a second pill (REQ-009). // always trails the subtitle as plain text — never a second pill (REQ-009).
const historical = $derived(m.timeline_layer_historical_suffix()); const historical = $derived(m.timeline_layer_historical_suffix());
// A HISTORICAL event is never derived, so canEditEvent's derived check is a // A HISTORICAL event is never derived, so the edit gate is just the curator
// no-op here — the gate is the curator flag plus a real eventId (#842 REQ-006/008). // flag plus a real eventId (#842 REQ-006/008).
const canEdit = $derived(canEditEvent(entry, canWrite)); const canEdit = $derived(canWrite && entry.eventId != null);
</script> </script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center"> <div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">

View File

@@ -3,10 +3,14 @@ import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte'; import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte'; import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte'; import YearLetterStrip from './YearLetterStrip.svelte';
import EventCluster from './EventCluster.svelte'; import LetterBucket from './LetterBucket.svelte';
import { isDense } from './timelineDensity'; import { isDense } from './timelineDensity';
import { entryKey } from './entryKey'; import { entryKey } from './entryKey';
import { splitYearLetters, type EventCluster as EventClusterModel } from './eventClustering'; import {
bucketLetters,
type GroupingMode,
type LetterBucket as LetterBucketModel
} from './timelineGrouping';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO']; type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
@@ -14,111 +18,102 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/** /**
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events * One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
* render in DTO order as pills/world-bands; entries are never re-sorted (REQ-003). * 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).
* *
* A curated event with letters linked to it (#850) becomes a contained event card: * In Ereignis/Thema mode (#827) the event pills/world-bands render identically
* the event IS the card header and its linked letters sit inside (no separate pill — * (REQ-001); only the loose letters re-bundle into per-year buckets below them
* REQ-002). A curated event with letters in another year band renders here as a * (REQ-002/003/004). Datum mode is the original individual-card / density-strip
* cross-year text-header card (REQ-004). An event with no linked letters stays a * path, untouched.
* plain pill/world-band (REQ-005).
*
* Every other letter (no linkedEventId, or linking to an event the #780 layer filter
* removed) stays loose: alternating left/right while the band holds ≤ 12 such loose
* letters (REQ-006), folding into a single month-density strip above that (REQ-007).
* The loose-letter layout and the strip count ONLY these loose letters — clustered
* letters never re-appear loose (REQ-007).
*/ */
let { let {
year, year,
canWrite = false, canWrite = false,
eventLookup groupingMode = 'date',
eventLookup = new Map<string, string>()
}: { }: {
year: TimelineYearDTO; year: TimelineYearDTO;
canWrite?: boolean; canWrite?: boolean;
groupingMode?: GroupingMode;
eventLookup?: Map<string, string>; eventLookup?: Map<string, string>;
} = $props(); } = $props();
type Row = type Row =
| { t: 'event'; entry: TimelineEntryDTO } | { t: 'event'; entry: TimelineEntryDTO }
| { t: 'eventcard'; event?: TimelineEntryDTO; cluster: EventClusterModel } | { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' } | { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' }; | { t: 'strip' }
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
// Split this band's letters into event clusters and the loose remainder once; the loose const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
// list alone drives the alternating layout and the density strip (REQ-007). const dense = $derived(isDense(letters.length));
const split = $derived( const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
splitYearLetters(
year.entries.filter((e) => e.kind === 'LETTER'),
eventLookup
)
);
const loose = $derived(split.loose);
const dense = $derived(isDense(loose.length));
// Clusters keyed by eventId (built once in splitYearLetters): row assembly looks a letter's
// disposition up in O(1) — `byEvent.has(linkedEventId)` — instead of scanning the loose array
// per letter (was O(L²) on a dense band), and resolves an event's card with `byEvent.get`.
const byEvent = $derived(split.byEvent);
// Event ids that have a same-year EVENT entry in THIS band: those clusters render as that
// event's header (at the EVENT position); every other cluster is cross-year (REQ-004/015).
const sameYearEventIds = $derived.by<Record<string, true>>(() => {
const ids: Record<string, true> = {};
for (const entry of year.entries) {
if (entry.kind === 'EVENT' && entry.eventId) ids[entry.eventId] = true;
}
return ids;
});
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const out: Row[] = []; const out: Row[] = [];
const emitted: Record<string, true> = {};
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) { // Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
if (entry.kind === 'EVENT') { // becomes the contained card's header (no separate pill — its title reads once, #827
// A curated event whose letters live in THIS band becomes the contained card's // redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
// header — its title reads once, no separate pill (REQ-002). Otherwise it stays a // lives in another year band (or was filtered out) renders as a text-header card here, and
// plain pill/world-band (REQ-005). // the unlinked letters fall to the single "Weitere Briefe" drawer (REQ-003/006/019).
const cluster = entry.eventId ? byEvent.get(entry.eventId) : undefined; if (groupingMode === 'event') {
if (cluster) { const buckets = bucketLetters(letters, 'event', eventLookup);
out.push({ t: 'eventcard', event: entry, cluster }); const sameYearBucket = (id: string | undefined) =>
emitted[cluster.eventId] = true; id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
} else { for (const entry of year.entries) {
out.push({ t: 'event', entry }); 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 });
} }
continue;
}
const cluster = entry.linkedEventId ? byEvent.get(entry.linkedEventId) : undefined;
if (!cluster) {
// A loose letter (not clustered): alternate while sparse, or fold the whole loose set
// into one density strip (inserted once, at the first loose letter) when dense.
if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
}
continue;
}
// A clustered letter. A same-year cluster is emitted at its EVENT entry, so skip it here.
// A cross-year cluster has no EVENT anchor in this band — emit its ✉ card HERE, at the
// position of its earliest linked letter, so the band stays in strict time order (REQ-015).
if (!sameYearEventIds[cluster.eventId] && !emitted[cluster.eventId]) {
out.push({ t: 'eventcard', cluster });
emitted[cluster.eventId] = true;
} }
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) {
if (entry.kind === 'EVENT') {
out.push({ t: 'event', entry });
} else if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
}
}
return out; return out;
}); });
function rowKey(row: Row): string { function rowKey(row: Row): string {
if (row.t === 'strip') return `strip-${year.year}`; if (row.t === 'strip') return `strip-${year.year}`;
if (row.t === 'eventcard') return `evcard:${row.cluster.eventId}`; if (row.t === 'bucket') return row.bucket.key;
return entryKey(row.entry); return entryKey(row.entry);
} }
</script> </script>
@@ -142,10 +137,11 @@ function rowKey(row: Row): string {
<EventPill entry={row.entry} canWrite={canWrite} /> <EventPill entry={row.entry} canWrite={canWrite} />
{/if} {/if}
{:else if row.t === 'eventcard'} {:else if row.t === 'eventcard'}
<EventCluster <LetterBucket
letters={row.cluster.letters} bucket={row.bucket}
event={row.event} mode="event"
title={row.cluster.title} year={year.year}
event={row.entry}
canWrite={canWrite} canWrite={canWrite}
/> />
{:else if row.t === 'letter'} {:else if row.t === 'letter'}
@@ -153,8 +149,10 @@ function rowKey(row: Row): string {
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span> <span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
<LetterCard entry={row.entry} /> <LetterCard entry={row.entry} />
</div> </div>
{:else if row.t === 'bucket'}
<LetterBucket bucket={row.bucket} mode={bucketMode} year={year.year} nested={row.nested} />
{:else} {:else}
<YearLetterStrip letters={loose} year={year.year} /> <YearLetterStrip letters={letters} year={year.year} />
{/if} {/if}
{/each} {/each}
</div> </div>

View File

@@ -166,125 +166,99 @@ describe('YearBand', () => {
}); });
}); });
describe('YearBand — inline event clustering (#850)', () => { describe('YearBand — grouping modes (#827)', () => {
const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; 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);
});
function curatedEvent(overrides = {}) { it('clusters loose letters under their linked event in Ereignis mode (REQ-002/003)', () => {
return makeEntry({ 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', kind: 'EVENT',
derived: false, type: 'HISTORICAL',
type: 'PERSONAL', precision: 'RANGE',
eventId: EV_ID, eventDate: '1914-01-01',
eventDate: '1916-07-06', eventDateEnd: '1918-12-31',
precision: 'DAY', title: 'Erster Weltkrieg',
title: 'Ein gewaltiger Stadtbrand',
senderName: '', senderName: '',
receiverName: '', receiverName: '',
documentId: undefined, documentId: undefined
...overrides
}); });
} const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1914-05-01' });
function linkedLetters(year: number, count: number, eventId = EV_ID) {
return Array.from({ length: count }, (_, i) =>
makeEntry({
eventDate: `${year}-05-10`,
documentId: `linked-${i}`,
title: `Brief ${i}`,
linkedEventId: eventId
})
);
}
const lookup = new Map([[EV_ID, 'Ein gewaltiger Stadtbrand']]);
it('renders a curated event with a same-year linked letter as one event-card, title once, no separate pill (REQ-002)', () => {
render(YearBand, { render(YearBand, {
year: makeYear(1916, [curatedEvent(), ...linkedLetters(1916, 1)]), year: makeYear(1914, [band, letter]),
eventLookup: lookup groupingMode: 'event',
eventLookup: new Map([['e1', 'Front']])
}); });
expect(document.querySelectorAll('[data-testid="event-card"]')).toHaveLength(1); expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
const titles = (document.body.textContent?.match(/Ein gewaltiger Stadtbrand/g) ?? []).length; expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
expect(titles).toBe(1); });
// the letter is inside the card, not a loose .letter-row
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(); expect(document.querySelector('a.lcard.ev')).not.toBeNull();
expect(document.querySelector('.letter-row')).toBeNull();
// no plain EventPill for it (the pill is the only floating .rounded-full wrapper)
expect(document.querySelector('.justify-center .rounded-full.border-brand-mint')).toBeNull();
}); });
it('renders a curated event with NO linked letters as a plain EventPill, no card (REQ-005)', () => { 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, { render(YearBand, {
year: makeYear(1916, [curatedEvent()]), year: makeYear(1917, [letter]),
eventLookup: lookup groupingMode: 'event',
eventLookup: new Map([['e1', 'Briefe von der Front']])
}); });
expect(document.querySelector('[data-testid="event-card"]')).toBeNull(); expect(document.body.textContent).toContain('Briefe von der Front');
// the curated EventPill is the bordered floating rounded-full wrapper
expect(
document.querySelector('.justify-center .rounded-full.border-brand-mint')
).not.toBeNull();
expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand');
});
it('renders loose (unlinked) letters in a sparse year as alternating .letter-row, no card (REQ-006)', () => {
const loose = manyLetters(1916, 3); // no linkedEventId
render(YearBand, { year: makeYear(1916, loose), eventLookup: lookup });
expect(document.querySelectorAll('.letter-row')).toHaveLength(3);
expect(document.querySelector('[data-testid="event-card"]')).toBeNull();
});
it('counts only loose letters in the density strip; event letters stay in the card (REQ-006/007)', () => {
// 15 loose letters fold into one strip; a 3-letter event card shows its 3.
const loose = manyLetters(1916, 15);
const year = makeYear(1916, [curatedEvent(), ...linkedLetters(1916, 3), ...loose]);
render(YearBand, { year, eventLookup: lookup });
// the event card holds 3 letters
expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(3);
// the loose letters fold into exactly one density strip
const strips = document.querySelectorAll('[data-testid="strip-expand"]');
expect(strips).toHaveLength(1);
// the strip card's count text is 15 (the loose letters), not 18 (REQ-006/007)
const stripCard = strips[0].closest('.max-w-md') as HTMLElement;
expect(stripCard.textContent).toContain('15');
expect(stripCard.textContent).not.toContain('18');
});
it('renders a cross-year cluster (event not in this band) as a ✉ text-header card holding it (REQ-004)', () => {
// The event id is in eventLookup but no matching EVENT entry sits in this band.
render(YearBand, {
year: makeYear(1917, linkedLetters(1917, 2)),
eventLookup: lookup
});
const card = document.querySelector('[data-testid="event-card"]');
expect(card).not.toBeNull();
expect(document.body.textContent).toContain('✉');
expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand');
// cross-year card carries no edit link and no pill
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
expect(document.querySelector('.justify-center .rounded-full.border-brand-mint')).toBeNull();
});
it('interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)', () => {
// Chronological band order (what the backend delivers): a February cross-year letter, then
// a November loose letter. The cross-year card must sit at its earliest letter's position —
// before the November loose letter — so the band still reads in strict time order.
const febLinked = makeEntry({
eventDate: '1917-02-10',
documentId: 'feb-linked',
title: 'Feldpostbrief',
linkedEventId: EV_ID
});
const novLoose = makeEntry({
eventDate: '1917-11-20',
documentId: 'nov-loose',
title: 'Brief im November'
});
render(YearBand, { year: makeYear(1917, [febLinked, novLoose]), eventLookup: lookup });
const card = document.querySelector('[data-testid="event-card"]') as HTMLElement;
const looseLink = document.querySelector('a[href="/documents/nov-loose"]') as HTMLElement;
expect(card).not.toBeNull();
expect(looseLink).not.toBeNull();
// the cross-year card precedes the later-dated loose letter in DOM order
expect(card.compareDocumentPosition(looseLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { getAccentConfig, canEditEvent } from './eventCardConfig'; import { getAccentConfig } from './eventCardConfig';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
@@ -51,24 +51,3 @@ describe('getAccentConfig', () => {
expect(cfg.accent).toBe('curated'); expect(cfg.accent).toBe('curated');
}); });
}); });
// The single source of the curator edit-affordance gate (CLAUDE.md's TimelineEntryDTO contract):
// a curated event shows its edit pencil only for a writer, never for a derived life-event or a
// null eventId. Shared by EventPill, WorldBand, and EventCluster (#850 finding #5).
describe('canEditEvent', () => {
it('allows a writer to edit a curated event with an eventId', () => {
expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), true)).toBe(true);
});
it('denies a viewer without write permission', () => {
expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), false)).toBe(false);
});
it('denies a derived life-event even for a writer', () => {
expect(canEditEvent(event({ derived: true, eventId: 'e-1' }), true)).toBe(false);
});
it('denies an event with no eventId even for a writer', () => {
expect(canEditEvent(event({ derived: false, eventId: undefined }), true)).toBe(false);
});
});

View File

@@ -36,16 +36,3 @@ export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
} }
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' }; return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
} }
/**
* The curator edit-affordance gate, in one place — the security-relevant contract documented on
* CLAUDE.md's `TimelineEntryDTO` row (`derived || eventId == null` → no edit link). A curated
* event's edit pencil shows only for a viewer with WRITE_ALL (`canWrite`), and only when it is a
* real curated event: never a derived life-event (nothing to edit) and never a null `eventId`.
* HISTORICAL events are never derived, so this also covers the world band. The gate is UX only —
* the #781 route guard + backend permission are the real boundary. Shared by EventPill, WorldBand,
* and EventCluster so the gate has a single source of truth (#850 finding #5).
*/
export function canEditEvent(entry: TimelineEntryDTO, canWrite: boolean): boolean {
return canWrite && !entry.derived && entry.eventId != null;
}

View File

@@ -1,141 +0,0 @@
import { describe, it, expect } from 'vitest';
import { buildEventLookup, splitYearLetters, CLUSTER_PREVIEW } from './eventClustering';
import { makeEntry } from './test-factories';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
const EV_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const EV_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const makeEvent = (overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO =>
makeEntry({ kind: 'EVENT', type: 'PERSONAL', documentId: undefined, ...overrides });
describe('eventClustering — buildEventLookup', () => {
it('maps a curated year-band event to its title, excluding undated-bucket events (#7)', () => {
const timeline: TimelineDTO = {
years: [
{
year: 1916,
entries: [makeEvent({ eventId: EV_A, title: 'Ein gewaltiger Stadtbrand' })]
}
],
undated: [makeEvent({ eventId: EV_B, title: 'Briefe von der Front' })]
};
const lookup = buildEventLookup(timeline);
expect(lookup.get(EV_A)).toBe('Ein gewaltiger Stadtbrand');
// An undated event renders as a plain pill in the undated bucket — out of clustering
// scope. Including it here would scatter its dated letters into orphaned ✉ cross-year
// cards detached from the pill (#7), so it must NOT enter the lookup.
expect(lookup.has(EV_B)).toBe(false);
expect(lookup.size).toBe(1);
});
it('ignores derived events (no eventId) and letters', () => {
const timeline: TimelineDTO = {
years: [
{
year: 1916,
entries: [
makeEvent({ eventId: undefined, title: 'Geburt' }), // derived
makeEntry({ kind: 'LETTER', documentId: 'doc-1' })
]
}
],
undated: []
};
expect(buildEventLookup(timeline).size).toBe(0);
});
it('excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)', () => {
const timeline: TimelineDTO = {
years: [
{ year: 1916, entries: [makeEvent({ eventId: EV_A, type: 'HISTORICAL', title: 'Somme' })] }
],
undated: []
};
const lookup = buildEventLookup(timeline);
expect(lookup.has(EV_A)).toBe(false);
expect(lookup.size).toBe(0);
});
it('skips an event with an empty or whitespace title — no bare ✉ card (#8)', () => {
const timeline: TimelineDTO = {
years: [
{
year: 1916,
entries: [
makeEvent({ eventId: EV_A, title: '' }),
makeEvent({ eventId: EV_B, title: ' ' })
]
}
],
undated: []
};
expect(buildEventLookup(timeline).size).toBe(0);
});
});
describe('eventClustering — splitYearLetters', () => {
it('exposes a CLUSTER_PREVIEW of 5', () => {
expect(CLUSTER_PREVIEW).toBe(5);
});
it('clusters letters by linkedEventId with matching counts', () => {
const lookup = new Map([[EV_A, 'Stadtbrand']]);
const letters = [
makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }),
makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: EV_A })
];
const { clusters, loose } = splitYearLetters(letters, lookup);
expect(clusters).toHaveLength(1);
expect(clusters[0].eventId).toBe(EV_A);
expect(clusters[0].title).toBe('Stadtbrand');
expect(clusters[0].letters).toHaveLength(2);
expect(loose).toHaveLength(0);
});
it('keeps a letter with no linkedEventId loose', () => {
const lookup = new Map([[EV_A, 'Stadtbrand']]);
const letters = [makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: undefined })];
const { clusters, loose } = splitYearLetters(letters, lookup);
expect(clusters).toHaveLength(0);
expect(loose).toHaveLength(1);
});
it('keeps a letter whose linkedEventId is absent from the lookup loose (filter-then-cluster, REQ-008)', () => {
const lookup = new Map([[EV_A, 'Stadtbrand']]);
const letters = [makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_B })];
const { clusters, loose } = splitYearLetters(letters, lookup);
expect(clusters).toHaveLength(0);
expect(loose).toHaveLength(1);
});
it('places each letter in exactly one place (REQ-007)', () => {
const lookup = new Map([[EV_A, 'Stadtbrand']]);
const letters = [
makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }),
makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: undefined }),
makeEntry({ kind: 'LETTER', documentId: 'd3', linkedEventId: EV_B })
];
const { clusters, loose } = splitYearLetters(letters, lookup);
const clustered = clusters.flatMap((c) => c.letters.length).reduce((a, b) => a + b, 0);
expect(clustered + loose.length).toBe(3);
expect(clustered).toBe(1);
expect(loose).toHaveLength(2);
});
it('keeps clusters in first-seen order', () => {
const lookup = new Map([
[EV_B, 'Front'],
[EV_A, 'Stadtbrand']
]);
const letters = [
makeEntry({ kind: 'LETTER', documentId: 'd1', linkedEventId: EV_A }),
makeEntry({ kind: 'LETTER', documentId: 'd2', linkedEventId: EV_B })
];
const { clusters } = splitYearLetters(letters, lookup);
expect(clusters.map((c) => c.eventId)).toEqual([EV_A, EV_B]);
});
});

View File

@@ -1,88 +0,0 @@
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/** Letters shown inside an event card before a "show more" toggle appears (#850, REQ-003). */
export const CLUSTER_PREVIEW = 5;
/** One contained event card's worth of letters within a year band (#850). */
export interface EventCluster {
/** The curated event's id — also the `{#each}` key. */
eventId: string;
/** The curated event's title (from the event lookup). */
title: string;
letters: TimelineEntryDTO[];
}
/** The result of splitting a year's letters into event clusters and the loose remainder. */
export interface SplitLetters {
clusters: EventCluster[];
loose: TimelineEntryDTO[];
/** Clusters keyed by `eventId` for O(1) lookup during row assembly (a letter's disposition is
* `byEvent.has(linkedEventId)`; an event's card is `byEvent.get(eventId)`). */
byEvent: Map<string, EventCluster>;
}
/**
* Maps each curated event present in the (already layer-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 #780 layer filter removed, so it falls back to a loose chronological
* letter (filter-then-cluster, REQ-008). Curated PERSONAL events carry an `eventId`; derived
* life-events and letters do not, so they never enter the lookup. HISTORICAL events are excluded
* too: a world event always keeps its full-width WorldBand and never clusters, even with linked
* letters (REQ-014) — those letters stay loose.
*
* Only year-band events are collected: an undated event renders as a plain pill in the undated
* bucket (out of clustering scope), so including it would scatter its dated letters into orphaned
* cross-year cards detached from that pill (#7).
*
* An event with an empty/whitespace title is skipped too — clustering under it would render a
* label-less `✉` mystery card; its letters stay loose instead (#8).
*/
export function buildEventLookup(timeline: TimelineDTO): Map<string, string> {
const lookup = new Map<string, string>();
const collect = (entries: TimelineEntryDTO[]) => {
for (const entry of entries) {
const title = entry.title?.trim();
if (entry.kind === 'EVENT' && entry.eventId && entry.type !== 'HISTORICAL' && title) {
lookup.set(entry.eventId, title);
}
}
};
for (const band of timeline.years) collect(band.entries);
return lookup;
}
/**
* Splits one year's `LETTER` entries into event clusters and the loose remainder. A letter joins
* the cluster keyed by its `linkedEventId` IFF that id is set AND present in `eventLookup`
* (filter-then-cluster, REQ-007/008); every other letter is loose and stays in the chronological
* flow (REQ-006). Clusters keep first-seen order; each letter appears in exactly one place.
*/
export function splitYearLetters(
letters: TimelineEntryDTO[],
eventLookup?: Map<string, string>
): SplitLetters {
const byEvent = new Map<string, EventCluster>();
const clusters: EventCluster[] = [];
const loose: TimelineEntryDTO[] = [];
for (const letter of letters) {
const eventId = letter.linkedEventId;
const title = eventId != null ? eventLookup?.get(eventId) : undefined;
if (eventId != null && title !== undefined) {
let cluster = byEvent.get(eventId);
if (!cluster) {
cluster = { eventId, title, letters: [] };
byEvent.set(eventId, cluster);
clusters.push(cluster);
}
cluster.letters.push(letter);
} else {
loose.push(letter);
}
}
return { clusters, loose, byEvent };
}

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

@@ -6,13 +6,12 @@ import { dirname, join } from 'node:path';
const timelineDir = dirname(fileURLToPath(import.meta.url)); const timelineDir = dirname(fileURLToPath(import.meta.url));
/** /**
* REQ-010 / CWE-79: inline event clustering renders curator event titles and import-derived * REQ-009 / CWE-79: the regroup touches every component under lib/timeline (the reused TagChip,
* letter titles + sender/receiver text through every component under lib/timeline (the reused * the .lcard.ev card, and the new tinted bucket-header chip). Curator/import-derived text must
* LetterCard, the new EventCluster card, the existing pills/bands/strip). That text must always * always render through Svelte's default `{...}` escaping — never `{@html}`. This grep gate fails
* render through Svelte's default `{...}` escaping — never `{@html}`. This grep gate fails loudly * loudly the moment any timeline component reaches for the raw-HTML directive.
* the moment any timeline component reaches for the raw-HTML directive.
*/ */
describe('lib/timeline never uses {@html} (REQ-010)', () => { describe('lib/timeline never uses {@html} (REQ-009)', () => {
it('no timeline component contains the raw-HTML directive', () => { it('no timeline component contains the raw-HTML directive', () => {
const components = readdirSync(timelineDir).filter((file) => file.endsWith('.svelte')); const components = readdirSync(timelineDir).filter((file) => file.endsWith('.svelte'));
expect(components.length).toBeGreaterThan(0); expect(components.length).toBeGreaterThan(0);

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 * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte'; import TimelineView from '$lib/timeline/TimelineView.svelte';
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte'; import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
import GroupingControl from '$lib/timeline/GroupingControl.svelte';
import { timelineMeta } from '$lib/timeline/timelineMeta'; import { timelineMeta } from '$lib/timeline/timelineMeta';
import { filterTimeline } from '$lib/timeline/timelineFilter'; import { filterTimeline } from '$lib/timeline/timelineFilter';
import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -17,12 +19,20 @@ let personalOn = $state(true);
let historicalOn = $state(true); let historicalOn = $state(true);
let lettersOn = $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( const filteredTimeline = $derived(
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn }) filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
); );
const filteredEmpty = $derived( const filteredEmpty = $derived(
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0 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 // 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 // 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 }) : m.timeline_events_count({ count: meta.eventCount })
); );
} }
// REQ-011: the toggle-free chronological view carries no grouping segment. segments.push(
groupingMode === 'event'
? m.timeline_grouping_event()
: groupingMode === 'thema'
? m.timeline_grouping_thema()
: m.timeline_grouping_date()
);
return segments.join(' · '); return segments.join(' · ');
}); });
</script> </script>
@@ -89,7 +105,14 @@ const metaLine = $derived.by(() => {
{/if} {/if}
</header> </header>
{#if hasContent} {#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 <TimelineFilters
bind:personalOn={personalOn} bind:personalOn={personalOn}
bind:historicalOn={historicalOn} bind:historicalOn={historicalOn}
@@ -112,7 +135,11 @@ const metaLine = $derived.by(() => {
</button> </button>
</div> </div>
{:else} {:else}
<TimelineView timeline={filteredTimeline} canWrite={data.canWrite} /> <TimelineView
timeline={filteredTimeline}
canWrite={data.canWrite}
groupingMode={groupingMode}
/>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -43,7 +43,7 @@ describe('/zeitstrahl page', () => {
expect(canvas?.querySelector('ol')).not.toBeNull(); expect(canvas?.querySelector('ol')).not.toBeNull();
}); });
it('renders the meta sub-line with range and counts, no grouping segment (REQ-011)', () => { it('renders the meta sub-line with range, counts, and grouping (REQ-002)', () => {
const dto = makeTimelineDTO({ const dto = makeTimelineDTO({
years: [ years: [
makeYear(1909, [ makeYear(1909, [
@@ -59,8 +59,7 @@ describe('/zeitstrahl page', () => {
expect(sub?.textContent).toContain('19091924'); expect(sub?.textContent).toContain('19091924');
expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 })); expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 }));
expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 })); expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 }));
// REQ-011: the toggle-free view drops the grouping meta segment. expect(sub?.textContent).toContain(m.timeline_grouping_date());
expect(sub?.textContent).not.toContain('Gruppierung');
}); });
it('omits the range segment when there are no year bands (REQ-002)', () => { it('omits the range segment when there are no year bands (REQ-002)', () => {
@@ -85,7 +84,7 @@ describe('/zeitstrahl page', () => {
const sub = document.querySelector('[data-testid="timeline-meta"]'); const sub = document.querySelector('[data-testid="timeline-meta"]');
expect(sub).not.toBeNull(); expect(sub).not.toBeNull();
expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 })); expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 }));
expect(sub?.textContent).not.toContain('Gruppierung'); expect(sub?.textContent).toContain(m.timeline_grouping_date());
}); });
it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => { it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => {
@@ -266,3 +265,61 @@ describe('/zeitstrahl curator affordances (#842)', () => {
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); 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');
});
});