Compare commits

..

36 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:25:00 +02:00
Marcel
f57e59b53c style(person): drop the unused gap-1.5 from the add-event link
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m43s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 19s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 5m14s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 6m27s
CI / fail2ban Regex (push) Successful in 55s
CI / Semgrep Security Scan (push) Successful in 26s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
The person-add-event link wraps a single text label, so the flex gap
never applies — only the sibling edit link (icon + text) needs it.
Removing the dead utility per the UI/UX review nit.

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:13:14 +02:00
Marcel
07771a7b34 docs(rtm): cite the edit-route 403 guard test for REQ-011
REQ-011 covers direct nav to both /zeitstrahl/events/new and
/{id}/edit, but the row cited only the /new guard + test. The
[id]/edit route shares the same requireWriteAll helper and already
carries its own 403 gating test (shipped with #781); cite both so the
traceability matches the requirement. Closes the Tester/Security
review note (no new test needed — the guard test already exists).

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:07:11 +02:00
Marcel
4d4266ba99 docs(rtm): trace #842 timeline-curator-affordances + i18n parity guard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m38s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 6m35s
CI / fail2ban Regex (pull_request) Successful in 52s
CI / Semgrep Security Scan (pull_request) Successful in 24s
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 30s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
Add REQ-001..011 rows for the timeline-curator-affordances feature (all
Done) and a messages.spec parity guard pinning timeline_add_event +
person_add_event across de/en/es. REQ-010/011 cite the existing #781
new-event route + its 403 guard test (no new production code).

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:19:28 +02:00
Marcel
446611e3cc feat(person): add a curator "Ereignis für diese Person" link to PersonCard
A curator on a person's page can now seed a timeline event from that
person: a gated link to #781's /zeitstrahl/events/new?personId={id},
which prefills the person and returns to /persons/{id} on save. Hidden
for a Reader. New i18n key person_add_event in de/en/es.

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:16:10 +02:00
Marcel
9118a10e4b feat(timeline): add the "Ereignis hinzufügen" CTA to the Zeitstrahl header
Wrap the heading in a flex-wrap header so the gated add-event link drops
below the title at narrow widths instead of overflowing; the CTA targets
#781's /zeitstrahl/events/new and only shows for a WRITE_ALL viewer. Also
thread canWrite into TimelineView so a curator sees the edit pencils on
the real page. New i18n key timeline_add_event in de/en/es.

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:12:52 +02:00
Marcel
11bcaf7cdb feat(timeline): add a WRITE_ALL-gated edit pencil to WorldBand
A curated HISTORICAL band had no edit affordance at all. Mirror the
EventPill pencil inline at the end of the band (data-testid="event-edit",
/edit href, aria-hidden glyph + sr-only Bearbeiten), gated on
canWrite && eventId != null. Thread canWrite to WorldBand through both
the year-band path and the undated bucket.

Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:08:14 +02:00
Marcel
cd238285ae feat(timeline): gate the EventPill edit pencil behind canWrite
Thread a gate-closed canWrite prop through TimelineView -> YearBand ->
EventPill and the undated bucket so a Reader never sees a dead-end edit
link. canEdit now also requires canWrite; the default false keeps an
un-threaded caller closed. The real boundary stays the #781 route guard
plus the backend permission -- this only hides the link.

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

Refs #780

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
d134990343 docs(rtm): trace #780 timeline-layer-filter requirements
Add the ten REQ-001…REQ-010 rows for the /zeitstrahl layer filter, each linked
to its implementation file(s) and test(s), all Done.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
21b1b3b835 test(timeline): add e2e journey + 375px axe for the layer filter
Playwright spec for /zeitstrahl: the primary journey (hide Letters → letter
cards vanish + trigger reports "1 aktiv" → reset restores) and a 375px axe pass
with the collapsible open in light and dark mode. Not skipped — #779 ships the
route. E2E is not wired into CI, so this runs locally only for now.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
33aff36867 test(timeline): guard the layer filter against navigation and fetch
A static boundary gate (mirroring the project's no-`{@html}` greps) that reads
TimelineFilters.svelte and /zeitstrahl/+page.svelte and fails if either ever
reintroduces goto(, url.searchParams, api.GET, or fetch( — the filter must stay
presentation-only and fully client-side.

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

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
c6fe61f06b feat(timeline): add TimelineFilters presentation component
A dumb, client-side layer-filter bar for /zeitstrahl: three $bindable layer
toggles (Personal/Historical/Letters) in a fieldset/legend, a sticky
"Filter (N aktiv)" trigger driven by hiddenLayerCount, and a reset text button
shown only when a layer is off. Toggles mirror the SearchFilterBar undated-toggle
markup (aria-pressed, ✓ glyph, 44px touch target, semantic tokens). The
collapsible slide honours prefers-reduced-motion by zeroing its duration. No
goto, no fetch — the route owns the state and the filtered view.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
182d014971 feat(timeline): add layer-filter i18n keys (de/en/es)
Eight Paraglide keys for the /zeitstrahl layer filter — layer labels, the
fieldset legend, the sticky trigger (distinct timeline_filter_trigger and
timeline_filter_trigger_active({count}) so it never reads "Filter (0 aktiv)"),
the reset button, and the filtered-empty message — added to all three locales.
messages.spec asserts their presence and the {count} signature.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
Marcel
dc9d1d52b3 feat(timeline): add client-side layer-filter helpers
Pure helpers for the /zeitstrahl layer filter: isDefaultState and
hiddenLayerCount drive the "Filter (N active)" trigger, and filterTimeline
derives a client-side view that hides personal/historical/letter layers and
drops year bands left empty. Letters ride the Letters layer, HISTORICAL events
the Historical layer, and curated PERSONAL plus derived life-events the
Personal layer.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:11:36 +02:00
8558567688 feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Closes #837

Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free.

Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`).

## What's in it
- **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped.
- **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint.
- New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6).
- `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision.
- Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated.
- Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019.

## Requirements
All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`.

## Test plan
- **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds.
- **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean.

## Notes for reviewers
- **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`.
- `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned).
- **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window.

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

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #841
2026-06-14 21:17:36 +02:00
Marcel
6dae4fe428 ci(nightly): surface a clear error when the Gitea API rejects the audit token
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m51s
CI / OCR Service Tests (push) Successful in 23s
CI / Unit & Component Tests (pull_request) Successful in 4m43s
CI / Backend Unit Tests (push) Successful in 5m6s
CI / fail2ban Regex (push) Successful in 48s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m10s
CI / Semgrep Security Scan (push) Successful in 23s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 13s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
The npm-audit job filed its tracking issue via `curl -sf`, which collapses
every HTTP >=400 into a bare "exit 22". When the NIGHTLY_AUDIT_TOKEN secret is
missing, expired, or under-scoped, the step failed with an opaque
`exitcode '22'` and no hint at the cause (run #6707).

Route all five API calls through an `api()` helper that reads the HTTP status
and, on >=400, emits an actionable `::error::` naming the status and the token
secret before failing — without ever echoing the token value. Extend the
in-workflow self-test (mocked curl) to cover both the success and HTTP-error
paths.

Closes #839
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:29:49 +02:00
44 changed files with 2876 additions and 85 deletions

View File

@@ -192,17 +192,52 @@ jobs:
REPO="${{ github.repository }}"
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
# --- Gitea API helper ---
# api METHOD URL [extra curl args...] — authenticated Gitea API call.
# `curl -sf` collapses every HTTP >=400 into a bare "exit 22", which
# surfaces as an opaque step failure (issue #839). Instead we read the
# status code and, on a >=400 response, print an actionable ::error::
# to stderr (so a calling command substitution does not swallow it) and
# return 1 — `set -e` then still fails the step. The token is never
# echoed (no set -x; never placed in the message).
api() {
local method="$1" url="$2"; shift 2
local resp http
resp=$(curl -s -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" "$@" -- "$url")
http=${resp##*$'\n'}
printf '%s' "${resp%$'\n'*}"
case "$http" in
2*|3*) return 0 ;;
401|403)
echo "::error::Gitea returned HTTP $http for $method ${url%%\?*} — the NIGHTLY_AUDIT_TOKEN secret is missing, expired, or lacks issue read+write scope; recreate the renovate_bot PAT and update the secret." >&2
return 1 ;;
*)
echo "::error::Gitea returned HTTP ${http:-(none)} for $method ${url%%\?*}." >&2
return 1 ;;
esac
}
# --- Self-test (mirrors ci.yml §Assert pattern) ---
# Tests the exact jq test() call used in the dedupe step, before any
# API call, so a broken matcher fails loudly early rather than silently
# opening duplicate issues. Proves the regex only — create-vs-update
# decision is exercised by the workflow_dispatch AC.
# Runs before any real API call so broken logic fails loudly early:
# (a) the jq title matcher used by the dedupe step — proves the regex
# only; the create-vs-update decision is exercised by the
# workflow_dispatch AC;
# (b) the api helper's HTTP-status handling, driven by a mocked curl so
# it needs no network — proves a 2xx returns the body and a >=400
# fails with an ::error:: instead of an opaque exit 22.
echo "{\"title\": \"${MARKER}\"}" \
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
( curl() { printf 'OK\n200'; }; [ "$(api GET selftest)" = "OK" ] ) \
|| { echo "FAIL: self-test — api helper dropped body on HTTP 200"; exit 1; }
( curl() { printf 'nope\n401'; }
if api GET selftest >/dev/null 2>/tmp/api_selftest_err; then exit 1; fi
grep -q '::error::' /tmp/api_selftest_err ) \
|| { echo "FAIL: self-test — api helper did not emit ::error:: on HTTP 401"; exit 1; }
echo "Self-test passed."
# --- Run audit ---
@@ -237,8 +272,7 @@ jobs:
# Renovate vuln PRs also carry the "security" label, so >1 open
# "security" issue WILL occur. Title-match (not just label) ensures
# we deduplicate only our own tracking issue.
OPEN_ISSUES=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
OPEN_ISSUES=$(api GET \
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
MATCHED=$(echo "$OPEN_ISSUES" | jq \
@@ -255,11 +289,10 @@ jobs:
--arg run_url "$RUN_URL" \
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
curl -sf -X PATCH \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
api PATCH \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
-d "$PAYLOAD" > /dev/null
echo "Updated tracking issue #${ISSUE_NUMBER}"
else
# Closed prior issue that recurs → new issue (not reopened).
@@ -268,24 +301,21 @@ jobs:
--arg title "$MARKER" \
--arg body "$ISSUE_BODY" \
'{"title": $title, "body": $body}')
CREATED=$(curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
CREATED=$(api POST \
"${GITEA_URL}/api/v1/repos/${REPO}/issues" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
-d "$PAYLOAD")
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
echo "Opened new tracking issue #${NEW_NUMBER}"
# Labels are ignored on issue create in Gitea — add in a follow-up call.
LABEL_IDS=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
LABEL_IDS=$(api GET \
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
api POST \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" \
-H "Content-Type: application/json" \
-d "{\"labels\": $LABEL_IDS}" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
-d "{\"labels\": $LABEL_IDS}" > /dev/null
fi
exit "$AUDIT_EXIT"

View File

@@ -2,7 +2,7 @@
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
> matrix is the part of the spec that *is* committed: it links each requirement to its issue,
> matrix is the part of the spec that _is_ committed: it links each requirement to its issue,
> the code that implements it, and the test(s) that prove it — so any requirement traces end
> to end, and any orphan (a requirement with no test) is visible on `main`.
@@ -24,30 +24,31 @@
## Matrix
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|---|---|---|---|---|---|---|
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
| ------- | ---------------------------------------------------------------------- | -------- | ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (\_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (\_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (\_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (\_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-005 | SPOUSE*OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
@@ -173,3 +174,45 @@
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
| REQ-001 | TimelineFilters is presentation-only (3 $bindable layer booleans + onChange); no goto/url.searchParams/api.GET/fetch | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#renders the three layer toggles with accessible names`, `#reflects a layer as pressed and flips it, firing onChange`; `timelineFilterBoundary.spec.ts` | Done |
| REQ-002 | route derives a client-side $derived filtered view, passes it to TimelineView; no goto/fetch on toggle | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#hides letter cards when the Letters layer is off ... with no fetch`; `timelineFilterBoundary.spec.ts` | Done |
| REQ-003 | Personal off → personal events (curated + derived life-events) hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides personal events — curated and derived`, `page.svelte.spec.ts#hides personal event cards` | Done |
| REQ-004 | Historical off → historical event entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides HISTORICAL events`, `page.svelte.spec.ts#hides historical event cards` | Done |
| REQ-005 | Letters off → letter entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides LETTER entries`, `page.svelte.spec.ts#hides letter cards` | Done |
| REQ-006 | zero visible → filter empty-state + one-click reset below the open bar (never blank, never generic empty) | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#shows the filtered-empty message + reset below the open bar`, `timelineFilter.spec.ts#drops year bands that become empty` | 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-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-001 | WRITE_ALL viewer → "Ereignis hinzufügen" link to /zeitstrahl/events/new in the wrapping Zeitstrahl header | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/messages/{de,en,es}.json` | `zeitstrahl/page.svelte.spec.ts#renders the add-event CTA in a wrapping header when the viewer can write` | Done |
| REQ-002 | viewer without WRITE_ALL → no add-event affordance on /zeitstrahl | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#renders no add-event CTA when the viewer cannot write` | Done |
| REQ-003 | WRITE_ALL viewer → person-page "Ereignis für diese Person" link to /zeitstrahl/events/new?personId={id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/messages/{de,en,es}.json` | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` | Done |
| REQ-004 | viewer without WRITE_ALL → no add-event affordance on /persons/{id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte` | `PersonCard.svelte.spec.ts#renders no add-event link to a reader` | Done |
| REQ-005 | WRITE_ALL → EventPill edit link /zeitstrahl/events/{eventId}/edit for a curated PERSONAL event | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#shows an edit affordance for a curated PERSONAL event when canWrite is true` | Done |
| REQ-006 | WRITE_ALL → WorldBand edit link /zeitstrahl/events/{eventId}/edit for a curated HISTORICAL event (new inline ✎) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#shows an edit affordance for a curated HISTORICAL event when canWrite is true`, `#mirrors the EventPill pencil` | Done |
| REQ-007 | viewer without WRITE_ALL → neither EventPill nor WorldBand renders an edit link | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#renders no edit affordance for a curated PERSONAL event when canWrite is false`, `WorldBand.svelte.spec.ts#renders no edit affordance for a curated HISTORICAL event when canWrite is false`, `TimelineView.svelte.spec.ts#renders no edit links in either path when canWrite is false` | Done |
| REQ-008 | derived OR null eventId → no edit link regardless of permission (contract preserved) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite`, `#shows no edit affordance for a derived event even with canWrite`, `WorldBand.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite` | 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-011 | non-curator direct nav to /zeitstrahl/events/new or /{id}/edit → 403 (existing #781 route guard, regression) | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (#781, unchanged — both share `requireWriteAll`) | `zeitstrahl/events/new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `zeitstrahl/events/[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
| REQ-001 | axis-fixed layers (life-events, pills, world-bands) render identically across all 3 modes; only loose letters re-bundle | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `grouping-event-layer-identity.svelte.spec.ts#renders the event pills and world-bands identically across all three grouping modes`, `YearBand.svelte.spec.ts#still renders the event world-band in Ereignis mode` | Done |
| REQ-002 | mode switch re-bundles loose letters over the layer-filtered view, no GET /api/timeline refetch | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/TimelineView.svelte` | `zeitstrahl/page.svelte.spec.ts#regroups loose letters under their event client-side`, `e2e/zeitstrahl-grouping.spec.ts#switching grouping modes issues no extra timeline fetch` | Done |
| REQ-003 | Ereignis clusters each loose letter under the curated event whose documents contain it | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#clusters letters under the curated event named by linkedEventId`, `YearBand.svelte.spec.ts#clusters loose letters under their linked event in Ereignis mode` | Done |
| REQ-004 | Thema buckets each loose letter per year under its primary root tag (rootTagId) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#buckets letters under their primary root tag with name and colour`, `YearBand.svelte.spec.ts#buckets loose letters under their root tag in Thema mode` | Done |
| REQ-005 | TimelineEntryDTO carries nullable linkedEventId, resolved in one batched membership pass | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id` | Done |
| REQ-005b | linkedEventId is nullable / not @Schema REQUIRED; null for non-letter entries | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `frontend/src/lib/generated/api.ts` (`linkedEventId?`) | `TimelineServiceTest#letter_in_no_curated_event_has_null_linkedEventId` | Done |
| REQ-006 | Ereignis: letter with null linkedEventId → per-year "Weitere Briefe" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops a letter with no linkedEventId into the fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Weitere Briefe" label` | Done |
| REQ-007 | Thema: untagged letter → per-year "Ohne Thema" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops an untagged letter into the "Ohne Thema" fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Ohne Thema" label` | Done |
| REQ-008 | multi-tagged letter appears under exactly one root tag, never duplicated | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts` | `timelineGrouping.spec.ts#places a letter in exactly one bucket` | Done |
| REQ-009 | tag names + hint render via `{...}` escaping; grep gate forbids `{@html}` in lib/timeline | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte`, `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/TagChip.svelte` | `BucketHeaderChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive` | Done |
| REQ-010 | grouping control is a keyboard-navigable role=radiogroup, ≥44px text segments, default Datum, dark-mode contrast | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#renders three radios inside a radiogroup`, `#moves the selection forward with the right arrow key`, `#each segment has a tap target of at least 44×44px`, `#defaults to Datum`; `e2e/zeitstrahl-grouping.spec.ts#no wcag2a/wcag2aa violations ... (light + dark)` | Done |
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant, **nested directly under its event pill** with no duplicate title (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards`, `YearBand.svelte.spec.ts#nests an event cluster under its pill in the same year without repeating the title` | Done |
| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done |
| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done |
| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done |
| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done |
| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done |
| REQ-020 | Grouped buckets are bound by a colour rail and carry compact cards; a bucket over `BUCKET_DENSE_THRESHOLD` (6) collapses to the month-density `YearLetterStrip` instead of flooding the timeline (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#isBucketDense`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#collapses an oversized bucket to the density strip instead of flooding cards`, `#binds a tag bucket together with a coloured left rail from its token`, `#renders compact cards for a small bucket`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect, type APIRequestContext } from '@playwright/test';
/**
* Global /zeitstrahl layer filter (#780). Runs against the real stack with the
* seeded admin session (auth.setup). Covers the primary journey (hide the
* Letters layer → letter cards vanish + the trigger reports one active filter →
* reset restores everything) and a 375px axe pass with the collapsible open in
* both light and dark mode.
*
* #779 (the /zeitstrahl route) is merged, so this spec is NOT skipped. Per
* e2e/CLAUDE.md, E2E is not yet wired into CI — this axe gate runs locally only
* for now.
*/
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 content (and a LetterCard to hide). */
async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) {
const senderId = await createPerson(request, 'Filter-Test', `Absender ${stamp()}`);
const receiverId = await createPerson(request, 'Filter-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 — layer filter (#780)', () => {
test('hiding the Letters layer removes letter cards and reports the active count; reset restores', async ({
page,
request
}) => {
// A sparse year keeps the seeded letter an individual card (not a dense strip).
const title = `E2E Filter Brief ${stamp()}`;
await seedDatedLetter(request, '1903-03-03', title);
await page.goto('/zeitstrahl');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByText(title)).toBeVisible();
await page.getByTestId('timeline-filter-trigger').click();
await page.getByTestId('timeline-filter-letters').click();
await expect(page.getByText(title)).toHaveCount(0);
await expect(page.getByTestId('timeline-filter-trigger')).toContainText('1 aktiv');
await page.getByTestId('timeline-filter-reset').click();
await expect(page.getByText(title)).toBeVisible();
});
test('no wcag2a/wcag2aa violations at 375px with the filter bar open (light + dark)', async ({
page,
request
}) => {
await seedDatedLetter(request, '1915-06-15', `E2E Filter A11y ${stamp()}`);
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/zeitstrahl');
await page.waitForSelector('[data-hydrated]');
// Open the collapsible so axe scans the toggles, not just the trigger.
await page.getByTestId('timeline-filter-trigger').click();
await expect(page.getByTestId('timeline-filter-personal')).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

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

@@ -188,6 +188,7 @@
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
"person_add_event": "Ereignis für diese Person",
"person_docs_heading": "Gesendete Dokumente",
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
"person_received_docs_heading": "Empfangene Dokumente",
@@ -1035,6 +1036,7 @@
"nav_geschichten": "Geschichten",
"nav_zeitstrahl": "Zeitstrahl",
"timeline_heading": "Zeitstrahl",
"timeline_add_event": "Ereignis hinzufügen",
"timeline_empty_state": "Noch keine Ereignisse.",
"timeline_undated_section": "Ohne Datum",
"timeline_unknown_person": "Unbekannt",
@@ -1048,6 +1050,19 @@
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"timeline_grouping_date": "Gruppierung: Datum",
"timeline_grouping_event": "Gruppierung: Ereignis",
"timeline_grouping_thema": "Gruppierung: Thema",
"timeline_grouping_aria_label": "Gruppierung",
"timeline_grouping_segment_date": "Datum",
"timeline_grouping_segment_event": "Ereignis",
"timeline_grouping_segment_thema": "Thema",
"timeline_grouping_segment_date_short": "Dat.",
"timeline_grouping_segment_event_short": "Ereig.",
"timeline_grouping_segment_thema_short": "Thema",
"timeline_grouping_disabled_reason": "Briefe sind ausgeblendet es gibt nichts zu gruppieren.",
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
"timeline_bucket_other_letters": "Weitere Briefe",
"timeline_bucket_no_topic": "Ohne Thema",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_letter_glyph_label": "Brief",
@@ -1057,6 +1072,14 @@
"timeline_events_count": "{count} Ereignisse",
"timeline_letters_count_singular": "1 Brief",
"timeline_events_count_singular": "1 Ereignis",
"timeline_filter_label_layers": "Ebenen anzeigen",
"timeline_filter_layer_personal": "Persönliche Ereignisse",
"timeline_filter_layer_historical": "Historische Ereignisse",
"timeline_filter_layer_letters": "Briefe",
"timeline_filter_trigger": "Filter",
"timeline_filter_trigger_active": "Filter ({count} aktiv)",
"timeline_filter_reset": "Filter zurücksetzen",
"timeline_filter_empty_state": "Keine Einträge entsprechen diesen Filtern.",
"event_editor_new_title": "Neues Ereignis",
"event_editor_edit_title": "Ereignis bearbeiten",
"event_editor_section_when": "Wann",

View File

@@ -188,6 +188,7 @@
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
"person_year_error": "Please enter a four-digit year",
"person_years_error_order": "Birth year must be before death year",
"person_add_event": "Add event for this person",
"person_docs_heading": "Sent documents",
"person_no_docs": "This person has not yet been linked as a sender.",
"person_received_docs_heading": "Received documents",
@@ -1035,6 +1036,7 @@
"nav_geschichten": "Stories",
"nav_zeitstrahl": "Timeline",
"timeline_heading": "Timeline",
"timeline_add_event": "Add event",
"timeline_empty_state": "No events yet.",
"timeline_undated_section": "Without Date",
"timeline_unknown_person": "Unknown",
@@ -1048,6 +1050,19 @@
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"timeline_grouping_date": "Grouping: Date",
"timeline_grouping_event": "Grouping: Event",
"timeline_grouping_thema": "Grouping: Topic",
"timeline_grouping_aria_label": "Grouping",
"timeline_grouping_segment_date": "Date",
"timeline_grouping_segment_event": "Event",
"timeline_grouping_segment_thema": "Topic",
"timeline_grouping_segment_date_short": "Date",
"timeline_grouping_segment_event_short": "Event",
"timeline_grouping_segment_thema_short": "Topic",
"timeline_grouping_disabled_reason": "Letters are hidden — there is nothing to group.",
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
"timeline_bucket_other_letters": "More letters",
"timeline_bucket_no_topic": "No topic",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_letter_glyph_label": "Letter",
@@ -1057,6 +1072,14 @@
"timeline_events_count": "{count} events",
"timeline_letters_count_singular": "1 letter",
"timeline_events_count_singular": "1 event",
"timeline_filter_label_layers": "Show layers",
"timeline_filter_layer_personal": "Personal events",
"timeline_filter_layer_historical": "Historical events",
"timeline_filter_layer_letters": "Letters",
"timeline_filter_trigger": "Filter",
"timeline_filter_trigger_active": "Filter ({count} active)",
"timeline_filter_reset": "Reset filters",
"timeline_filter_empty_state": "No entries match these filters.",
"event_editor_new_title": "New event",
"event_editor_edit_title": "Edit event",
"event_editor_section_when": "When",

View File

@@ -188,6 +188,7 @@
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
"person_year_error": "Introduzca un año de cuatro dígitos",
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
"person_add_event": "Añadir evento para esta persona",
"person_docs_heading": "Documentos enviados",
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
"person_received_docs_heading": "Documentos recibidos",
@@ -1035,6 +1036,7 @@
"nav_geschichten": "Historias",
"nav_zeitstrahl": "Línea de tiempo",
"timeline_heading": "Línea de tiempo",
"timeline_add_event": "Añadir evento",
"timeline_empty_state": "Aún no hay eventos.",
"timeline_undated_section": "Sin Fecha",
"timeline_unknown_person": "Desconocido",
@@ -1048,6 +1050,19 @@
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"timeline_grouping_date": "Agrupación: Fecha",
"timeline_grouping_event": "Agrupación: Evento",
"timeline_grouping_thema": "Agrupación: Tema",
"timeline_grouping_aria_label": "Agrupación",
"timeline_grouping_segment_date": "Fecha",
"timeline_grouping_segment_event": "Evento",
"timeline_grouping_segment_thema": "Tema",
"timeline_grouping_segment_date_short": "Fecha",
"timeline_grouping_segment_event_short": "Evento",
"timeline_grouping_segment_thema_short": "Tema",
"timeline_grouping_disabled_reason": "Las cartas están ocultas: no hay nada que agrupar.",
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
"timeline_bucket_other_letters": "Más cartas",
"timeline_bucket_no_topic": "Sin tema",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_letter_glyph_label": "Carta",
@@ -1057,6 +1072,14 @@
"timeline_events_count": "{count} eventos",
"timeline_letters_count_singular": "1 carta",
"timeline_events_count_singular": "1 evento",
"timeline_filter_label_layers": "Mostrar capas",
"timeline_filter_layer_personal": "Eventos personales",
"timeline_filter_layer_historical": "Eventos históricos",
"timeline_filter_layer_letters": "Cartas",
"timeline_filter_trigger": "Filtro",
"timeline_filter_trigger_active": "Filtro ({count} activos)",
"timeline_filter_reset": "Restablecer filtros",
"timeline_filter_empty_state": "Ninguna entrada coincide con estos filtros.",
"event_editor_new_title": "Nuevo evento",
"event_editor_edit_title": "Editar evento",
"event_editor_section_when": "Cuándo",

View File

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

View File

@@ -98,4 +98,68 @@ describe('message key parity', () => {
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
});
// #780 REQ-010: the layer-filter strings are Paraglide keys in every locale.
// timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count},
// ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)".
it('zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)', () => {
const requiredKeys = [
'timeline_filter_label_layers',
'timeline_filter_layer_personal',
'timeline_filter_layer_historical',
'timeline_filter_layer_letters',
'timeline_filter_trigger',
'timeline_filter_trigger_active',
'timeline_filter_reset',
'timeline_filter_empty_state'
];
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 active-count key carries the established {count} placeholder
expect(de.timeline_filter_trigger_active).toContain('{count}');
expect(en.timeline_filter_trigger_active).toContain('{count}');
expect(es.timeline_filter_trigger_active).toContain('{count}');
});
// #842: the two curator-affordance CTA labels (Zeitstrahl header + person page)
// are Paraglide keys present in every locale; the edit pencils reuse btn_edit.
it('curator-affordance CTA keys are present in all locales (#842)', () => {
for (const key of ['timeline_add_event', 'person_add_event']) {
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);
}
});
// #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

@@ -10,9 +10,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* Centered axis pill for a derived life-event or a curated PERSONAL event
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
* (REQ-018). An edit affordance shows only for a curated event with an eventId
* (never derived, never null — REQ-008).
* (never derived, never null — REQ-008) and only for a curator who holds
* WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The
* gate is UX only; the real boundary is the #781 route guard + backend permission.
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
@@ -24,7 +26,7 @@ const provenance = $derived(
// 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(!entry.derived && entry.eventId != null);
const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
</script>
<div class="flex justify-center">

View File

@@ -51,8 +51,9 @@ describe('EventPill', () => {
expect(srOnly?.textContent).toBe('Geburt');
});
it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
it('shows an edit affordance for a curated PERSONAL event when canWrite is true (REQ-005)', () => {
render(EventPill, {
canWrite: true,
entry: makeEntry({
kind: 'EVENT',
derived: false,
@@ -66,11 +67,45 @@ describe('EventPill', () => {
});
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
expect(edit).not.toBeNull();
expect(edit?.getAttribute('href')).toContain(EVENT_ID);
expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${EVENT_ID}/edit`);
});
it('shows no edit affordance when eventId is null (REQ-008)', () => {
it('renders no edit affordance for a curated PERSONAL event when canWrite is false (REQ-007)', () => {
render(EventPill, {
canWrite: false,
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: EVENT_ID,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('renders no edit affordance when the canWrite prop is omitted (gate-closed default) (REQ-007)', () => {
render(EventPill, {
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: EVENT_ID,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => {
render(EventPill, {
canWrite: true,
entry: makeEntry({
kind: 'EVENT',
derived: false,
@@ -85,8 +120,8 @@ describe('EventPill', () => {
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('shows no edit affordance for a derived event (REQ-008)', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
it('shows no edit affordance for a derived event even with canWrite (REQ-008)', () => {
render(EventPill, { canWrite: true, entry: derived('MARRIAGE', 'Heirat') });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { slide } from 'svelte/transition';
import { hiddenLayerCount, isDefaultState, type TimelineLayerFilters } from './timelineFilter';
// Presentation-only layer filter for the global /zeitstrahl (#780, REQ-001).
// Holds no timeline data and never navigates or fetches — the route owns the
// $state and derives the filtered view. Three $bindable layer booleans plus an
// onChange notification hook are the whole contract.
let {
personalOn = $bindable(true),
historicalOn = $bindable(true),
lettersOn = $bindable(true),
onChange
}: {
personalOn?: boolean;
historicalOn?: boolean;
lettersOn?: boolean;
onChange?: () => void;
} = $props();
let open = $state(false);
// Reuse the reduced-motion guard expression from documents/[id]/+page.svelte:57
// for a new purpose — zeroing the slide duration so the collapsible opens
// instantly when the reader prefers reduced motion (REQ-009).
const prefersReducedMotion = $derived(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
const slideDuration = $derived(prefersReducedMotion ? 0 : 200);
const filters: TimelineLayerFilters = $derived({ personalOn, historicalOn, lettersOn });
const hiddenCount = $derived(hiddenLayerCount(filters));
const anyLayerOff = $derived(!isDefaultState(filters));
function reset() {
personalOn = true;
historicalOn = true;
lettersOn = true;
onChange?.();
}
</script>
{#snippet layerToggle(label: string, testid: string, pressed: boolean, toggle: () => void)}
<button
type="button"
data-testid={testid}
aria-pressed={pressed}
onclick={toggle}
class="inline-flex min-h-[44px] items-center gap-2 rounded border px-3 font-sans text-sm transition-colors {pressed
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-muted text-ink-2 hover:bg-line'}"
>
<span
aria-hidden="true"
class="inline-flex h-4 w-4 items-center justify-center rounded-sm border {pressed
? 'border-primary-fg bg-primary-fg/20'
: 'border-ink-3'}"
>
{#if pressed}{/if}
</span>
{label}
</button>
{/snippet}
<section class="mb-6">
<!-- Sticky trigger kept in document flow so the hidden-layer count stays
visible without clipping timeline content (REQ-007). -->
<div class="sticky top-16 z-20 bg-canvas py-2">
<button
type="button"
data-testid="timeline-filter-trigger"
aria-expanded={open}
aria-controls={open ? 'timeline-filter-panel' : undefined}
onclick={() => (open = !open)}
class="inline-flex min-h-[44px] items-center gap-2 rounded border border-line bg-surface px-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
>
{hiddenCount === 0
? m.timeline_filter_trigger()
: m.timeline_filter_trigger_active({ count: hiddenCount })}
</button>
</div>
{#if open}
<div id="timeline-filter-panel" transition:slide={{ duration: slideDuration }}>
<fieldset class="mt-2 rounded-sm border border-line bg-surface p-4">
<legend class="px-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.timeline_filter_label_layers()}
</legend>
<div class="flex flex-wrap gap-2">
{@render layerToggle(
m.timeline_filter_layer_personal(),
'timeline-filter-personal',
personalOn,
() => {
personalOn = !personalOn;
onChange?.();
}
)}
{@render layerToggle(
m.timeline_filter_layer_historical(),
'timeline-filter-historical',
historicalOn,
() => {
historicalOn = !historicalOn;
onChange?.();
}
)}
{@render layerToggle(
m.timeline_filter_layer_letters(),
'timeline-filter-letters',
lettersOn,
() => {
lettersOn = !lettersOn;
onChange?.();
}
)}
</div>
{#if anyLayerOff}
<button
type="button"
data-testid="timeline-filter-reset"
onclick={reset}
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
>
{m.timeline_filter_reset()}
</button>
{/if}
</fieldset>
</div>
{/if}
</section>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import * as m from '$lib/paraglide/messages.js';
import TimelineFilters from './TimelineFilters.svelte';
afterEach(() => cleanup());
const allOn = () => ({ personalOn: true, historicalOn: true, lettersOn: true, onChange: vi.fn() });
async function openBar() {
await page.getByTestId('timeline-filter-trigger').click();
}
describe('TimelineFilters', () => {
it('renders the three layer toggles with accessible names inside a labelled group (REQ-001)', async () => {
render(TimelineFilters, allOn());
await openBar();
await expect
.element(page.getByRole('button', { name: m.timeline_filter_layer_personal() }))
.toBeVisible();
await expect
.element(page.getByRole('button', { name: m.timeline_filter_layer_historical() }))
.toBeVisible();
await expect
.element(page.getByRole('button', { name: m.timeline_filter_layer_letters() }))
.toBeVisible();
// the fieldset legend groups the toggles
await expect.element(page.getByText(m.timeline_filter_label_layers())).toBeVisible();
});
it('reflects a layer as pressed and flips it, firing onChange (REQ-001)', async () => {
const props = allOn();
render(TimelineFilters, props);
await openBar();
const personal = page.getByTestId('timeline-filter-personal');
await expect.element(personal).toHaveAttribute('aria-pressed', 'true');
await personal.click();
await expect.element(personal).toHaveAttribute('aria-pressed', 'false');
expect(props.onChange).toHaveBeenCalled();
});
it('shows a plain trigger when all layers are on and a count once a layer is hidden (REQ-007/010)', async () => {
render(TimelineFilters, allOn());
const trigger = page.getByTestId('timeline-filter-trigger');
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger());
await expect.element(trigger).not.toHaveTextContent('aktiv');
await trigger.click();
await page.getByTestId('timeline-filter-letters').click();
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger_active({ count: 1 }));
});
it('gives the trigger a 44px touch target (REQ-007)', async () => {
render(TimelineFilters, allOn());
await expect.element(page.getByTestId('timeline-filter-trigger')).toHaveClass(/min-h-\[44px\]/);
});
it('hides the reset button by default and restores all layers when activated (REQ-008)', async () => {
const props = allOn();
render(TimelineFilters, props);
await openBar();
const reset = page.getByTestId('timeline-filter-reset');
// absent (not just hidden) while every layer is on
expect(reset.query()).toBeNull();
await page.getByTestId('timeline-filter-historical').click();
await expect.element(reset).toBeVisible();
await reset.click();
await expect
.element(page.getByTestId('timeline-filter-historical'))
.toHaveAttribute('aria-pressed', 'true');
await expect.poll(() => reset.query()).toBeNull();
expect(props.onChange).toHaveBeenCalled();
});
});

View File

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

View File

@@ -105,6 +105,7 @@ describe('TimelineView', () => {
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
render(TimelineView, {
canWrite: true,
timeline: makeTimelineDTO({
undated: [
makeEntry({
@@ -125,8 +126,8 @@ describe('TimelineView', () => {
// The event renders inside the undated section…
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Auswanderung');
// …as an EventPill (its edit affordance), never as a letter card linking
// to /documents/undefined with "Unbekannt → Unbekannt".
// …as an EventPill (its edit affordance, threaded canWrite), never as a
// letter card linking to /documents/undefined with "Unbekannt → Unbekannt".
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
expect(document.body.textContent).not.toContain('Unbekannt');
@@ -276,4 +277,68 @@ describe('TimelineView', () => {
);
expect(sides).toEqual(['left', 'right', 'left', 'right']);
});
// A curated PERSONAL event reachable through both dispatch paths: the year-band
// path (TimelineView → YearBand → EventPill) and the undated bucket
// (TimelineView → EventPill). canWrite must thread to both (REQ-009).
const curated = (eventId: string, title: string) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId,
title,
senderName: '',
receiverName: '',
documentId: undefined
});
const bothPaths = () =>
makeTimelineDTO({
years: [makeYear(1924, [curated('banded', 'Umzug nach Berlin')])],
undated: [curated('undated', 'Auswanderung')]
});
it('threads canWrite to a curated event in both a year band and the undated bucket (REQ-009)', () => {
render(TimelineView, { canWrite: true, timeline: bothPaths() });
const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) =>
a.getAttribute('href')
);
expect(hrefs).toContain('/zeitstrahl/events/banded/edit');
expect(hrefs).toContain('/zeitstrahl/events/undated/edit');
});
it('renders no edit links in either path when canWrite is false (REQ-007/009)', () => {
render(TimelineView, { canWrite: false, timeline: bothPaths() });
expect(document.querySelectorAll('[data-testid="event-edit"]')).toHaveLength(0);
});
it('threads canWrite to a curated HISTORICAL world band in both paths (REQ-006/009)', () => {
const world = (eventId: string, title: string) =>
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
eventId,
precision: 'YEAR',
eventDate: '1929-01-01',
eventDateEnd: undefined,
title,
senderName: '',
receiverName: '',
documentId: undefined
});
render(TimelineView, {
canWrite: true,
timeline: makeTimelineDTO({
years: [makeYear(1929, [world('wb', 'Weltwirtschaftskrise')])],
undated: [world('wu', 'Unbekanntes Weltereignis')]
})
});
const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) =>
a.getAttribute('href')
);
expect(hrefs).toContain('/zeitstrahl/events/wb/edit');
expect(hrefs).toContain('/zeitstrahl/events/wu/edit');
});
});

View File

@@ -11,9 +11,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* (REQ-009). A RANGE carries a visible span pill ("19141918") with a Zeitraum
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
* uses text-ink-2 to stay AA in both themes (REQ-019).
* uses text-ink-2 to stay AA in both themes (REQ-019). A curator (`canWrite`,
* gate-closed by default) gets an inline edit pencil for a curated event with an
* eventId — #842 REQ-006/007/008; UX gate only, the #781 route guard is the boundary.
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
@@ -24,6 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
// always trails the subtitle as plain text — never a second pill (REQ-009).
const historical = $derived(m.timeline_layer_historical_suffix());
// A HISTORICAL event is never derived, so the edit gate is just the curator
// flag plus a real eventId (#842 REQ-006/008).
const canEdit = $derived(canWrite && entry.eventId != null);
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
@@ -46,4 +51,14 @@ const historical = $derived(m.timeline_layer_historical_suffix());
<!-- Single trailing "· historisch" register, after the title and any
span pill / date — one render site, consistent separator (REQ-009). -->
<span class="ml-2 font-sans text-xs text-ink-3">· {historical}</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.eventId}/edit"
class="ml-2 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>

View File

@@ -73,4 +73,35 @@ describe('WorldBand', () => {
expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix());
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
});
const HIST_EVENT_ID = '44444444-4444-4444-4444-444444444444';
it('shows an edit affordance for a curated HISTORICAL event when canWrite is true (REQ-006)', () => {
render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) });
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
expect(edit).not.toBeNull();
expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${HIST_EVENT_ID}/edit`);
});
it('mirrors the EventPill pencil: aria-hidden ✎ glyph + sr-only Bearbeiten label (REQ-006)', () => {
render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) });
const edit = document.querySelector('[data-testid="event-edit"]');
expect(edit?.querySelector('[aria-hidden="true"]')?.textContent).toBe('✎');
expect(edit?.querySelector('.sr-only')?.textContent).toBe(m.btn_edit());
});
it('renders no edit affordance for a curated HISTORICAL event when canWrite is false (REQ-007)', () => {
render(WorldBand, { canWrite: false, entry: historical({ eventId: HIST_EVENT_ID }) });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('renders no edit affordance when the canWrite prop is omitted (gate-closed default) (REQ-007)', () => {
render(WorldBand, { entry: historical({ eventId: HIST_EVENT_ID }) });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => {
render(WorldBand, { canWrite: true, entry: historical({ eventId: undefined }) });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
isDefaultState,
hiddenLayerCount,
filterTimeline,
ALL_LAYERS_ON,
type TimelineLayerFilters
} from './timelineFilter';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
// Entry factories pinned to the three layers the filter discriminates (#780).
const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides });
const curatedPersonal = (overrides = {}) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
documentId: undefined,
title: 'Umzug nach Berlin',
senderName: '',
receiverName: '',
...overrides
});
// Derived life-events carry type=PERSONAL (issue #776 REQ-009) — they belong to
// the Personal layer, not a fourth one.
const derivedLifeEvent = (overrides = {}) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: true,
derivedType: 'BIRTH',
documentId: undefined,
title: 'Geburt',
senderName: '',
receiverName: '',
...overrides
});
const historical = (overrides = {}) =>
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
documentId: undefined,
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
...overrides
});
const off = (overrides: Partial<TimelineLayerFilters>): TimelineLayerFilters => ({
...ALL_LAYERS_ON,
...overrides
});
describe('isDefaultState (REQ-007)', () => {
it('is true when all three layers are on', () => {
expect(isDefaultState(ALL_LAYERS_ON)).toBe(true);
});
it('is false when any single layer is off', () => {
expect(isDefaultState(off({ personalOn: false }))).toBe(false);
expect(isDefaultState(off({ historicalOn: false }))).toBe(false);
expect(isDefaultState(off({ lettersOn: false }))).toBe(false);
});
});
describe('hiddenLayerCount (REQ-007)', () => {
it('is 0 in the default all-on state', () => {
expect(hiddenLayerCount(ALL_LAYERS_ON)).toBe(0);
});
it('counts each layer that is off', () => {
expect(hiddenLayerCount(off({ lettersOn: false }))).toBe(1);
expect(hiddenLayerCount(off({ personalOn: false, historicalOn: false }))).toBe(2);
expect(hiddenLayerCount({ personalOn: false, historicalOn: false, lettersOn: false })).toBe(3);
});
});
describe('filterTimeline', () => {
it('returns every entry unchanged in the default all-on state', () => {
const dto = makeTimelineDTO({
years: [makeYear(1915, [letter(), historical(), curatedPersonal()])],
undated: [letter({ documentId: 'u1' })]
});
const result = filterTimeline(dto, ALL_LAYERS_ON);
expect(result.years[0].entries).toHaveLength(3);
expect(result.undated).toHaveLength(1);
});
it('hides LETTER entries when lettersOn is false, keeping events (REQ-005)', () => {
const dto = makeTimelineDTO({
years: [makeYear(1915, [letter(), historical(), curatedPersonal()])],
undated: [letter({ documentId: 'u1' })]
});
const result = filterTimeline(dto, off({ lettersOn: false }));
expect(result.years[0].entries.every((e) => e.kind !== 'LETTER')).toBe(true);
expect(result.years[0].entries).toHaveLength(2);
expect(result.undated).toHaveLength(0);
});
it('hides HISTORICAL events when historicalOn is false, keeping personal + letters (REQ-004)', () => {
const dto = makeTimelineDTO({
years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])]
});
const result = filterTimeline(dto, off({ historicalOn: false }));
const kept = result.years[0].entries;
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(false);
expect(kept.some((e) => e.kind === 'LETTER')).toBe(true);
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(true);
expect(kept).toHaveLength(3);
});
it('hides personal events — curated and derived — when personalOn is false (REQ-003)', () => {
const dto = makeTimelineDTO({
years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])]
});
const result = filterTimeline(dto, off({ personalOn: false }));
const kept = result.years[0].entries;
// neither the curated PERSONAL event nor the derived life-event survives
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(false);
expect(kept.some((e) => e.derived)).toBe(false);
// historical events and letters are untouched
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(true);
expect(kept.some((e) => e.kind === 'LETTER')).toBe(true);
expect(kept).toHaveLength(2);
});
it('drops year bands that become empty and filters the undated bucket (REQ-006)', () => {
const dto = makeTimelineDTO({
years: [
makeYear(1915, [letter()]), // becomes empty when letters are hidden
makeYear(1918, [historical()]) // survives
],
undated: [letter({ documentId: 'u1' }), historical({ documentId: undefined })]
});
const result = filterTimeline(dto, off({ lettersOn: false }));
expect(result.years).toHaveLength(1);
expect(result.years[0].year).toBe(1918);
expect(result.undated.every((e) => e.kind !== 'LETTER')).toBe(true);
expect(result.undated).toHaveLength(1);
});
it('does not mutate the input timeline', () => {
const dto = makeTimelineDTO({ years: [makeYear(1915, [letter(), historical()])] });
filterTimeline(dto, off({ lettersOn: false }));
expect(dto.years[0].entries).toHaveLength(2);
});
});

View File

@@ -0,0 +1,63 @@
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* The three visibility layers a reader can toggle on the global `/zeitstrahl`
* (#780). Purely a presentation concern — the whole timeline is loaded once by
* #779; these toggles derive a client-side filtered view of it.
*/
export interface TimelineLayerFilters {
/** Personal events — curated `PERSONAL` events and derived life-events. */
personalOn: boolean;
/** Historical events (`type === 'HISTORICAL'`). */
historicalOn: boolean;
/** Letters (`kind === 'LETTER'`). */
lettersOn: boolean;
}
/** The default view: every layer visible. */
export const ALL_LAYERS_ON: TimelineLayerFilters = {
personalOn: true,
historicalOn: true,
lettersOn: true
};
/** True when no layer is hidden — the default, all-on state (REQ-007). */
export function isDefaultState(filters: TimelineLayerFilters): boolean {
return filters.personalOn && filters.historicalOn && filters.lettersOn;
}
/** How many layers are currently hidden — the "N active" trigger count (REQ-007). */
export function hiddenLayerCount(filters: TimelineLayerFilters): number {
return (
(filters.personalOn ? 0 : 1) + (filters.historicalOn ? 0 : 1) + (filters.lettersOn ? 0 : 1)
);
}
/**
* Decides whether one entry survives the active layer toggles. A letter rides
* the Letters layer; a historical event the Historical layer; everything else
* (curated `PERSONAL` events and derived life-events, which also carry
* `type === 'PERSONAL'`) the Personal layer.
*/
function isVisible(entry: TimelineEntryDTO, filters: TimelineLayerFilters): boolean {
if (entry.kind === 'LETTER') return filters.lettersOn;
if (entry.type === 'HISTORICAL') return filters.historicalOn;
return filters.personalOn;
}
/**
* Derives a client-side filtered copy of the timeline (REQ-003/004/005/006).
* Year bands left empty by the active toggles are dropped so `TimelineView`
* never renders a hollow band, and the undated bucket is filtered the same way.
* Pure — the input DTO is never mutated.
*/
export function filterTimeline(timeline: TimelineDTO, filters: TimelineLayerFilters): TimelineDTO {
const years = timeline.years
.map((band) => ({ ...band, entries: band.entries.filter((e) => isVisible(e, filters)) }))
.filter((band) => band.entries.length > 0);
const undated = timeline.undated.filter((e) => isVisible(e, filters));
return { years, undated };
}

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// REQ-001/002: the layer filter is presentation-only and fully client-side. It
// must never navigate or fetch — the route derives the filtered view from
// already-loaded data. This static guard mirrors the project's existing
// grep-gates (e.g. the no-`{@html}` checks) and fails the build if a future
// edit reintroduces navigation or a network call into either file.
const read = (relative: string) =>
readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8');
const FILES = {
'TimelineFilters.svelte': read('./TimelineFilters.svelte'),
'/zeitstrahl/+page.svelte': read('../../routes/zeitstrahl/+page.svelte')
};
const FORBIDDEN: { label: string; pattern: RegExp }[] = [
{ label: 'goto(', pattern: /\bgoto\s*\(/ },
{ label: 'url.searchParams', pattern: /url\.searchParams/ },
{ label: 'api.GET', pattern: /\bapi\.GET\b/ },
{ label: 'fetch(', pattern: /\bfetch\s*\(/ }
];
describe('layer-filter boundary (REQ-001/002)', () => {
for (const [name, source] of Object.entries(FILES)) {
it(`${name} was found and is non-empty`, () => {
expect(source.length).toBeGreaterThan(0);
});
for (const { label, pattern } of FORBIDDEN) {
it(`${name} contains no ${label}`, () => {
expect(pattern.test(source), `${name} must not use ${label}`).toBe(false);
});
}
}
});

View File

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

View File

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

View File

@@ -128,7 +128,8 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
</div>
{/if}
<!-- Edit button — full width, outlined -->
<!-- Curator actions — full width, outlined. Both links are gated to
WRITE_ALL; the gate is UX only (the #781 route guard is the boundary). -->
{#if canWrite}
<a
href="/persons/{person.id}/edit"
@@ -142,6 +143,15 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
/>
{m.btn_edit()}
</a>
<!-- Opens #781's create form pre-seeded with this person; on save it
returns to /persons/{id} (originPersonId). #842 REQ-003/010. -->
<a
data-testid="person-add-event"
href="/zeitstrahl/events/new?personId={person.id}"
class="mt-2 flex min-h-[44px] w-full items-center justify-center rounded border border-line px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
>
{m.person_add_event()}
</a>
{/if}
</div>
</div>

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import PersonCard from './PersonCard.svelte';
afterEach(() => cleanup());
const PERSON_ID = '55555555-5555-5555-5555-555555555555';
const makePerson = (overrides = {}) => ({
id: PERSON_ID,
firstName: 'Karl',
lastName: 'Raddatz',
displayName: 'Karl Raddatz',
personType: 'PERSON',
...overrides
});
describe('PersonCard add-event affordance (#842)', () => {
it('shows an add-event link pre-seeded with the person to a curator (REQ-003)', () => {
render(PersonCard, { person: makePerson(), canWrite: true });
const add = document.querySelector(
'[data-testid="person-add-event"]'
) as HTMLAnchorElement | null;
expect(add).not.toBeNull();
expect(add?.getAttribute('href')).toBe(`/zeitstrahl/events/new?personId=${PERSON_ID}`);
});
it('renders no add-event link to a reader (REQ-004)', () => {
render(PersonCard, { person: makePerson(), canWrite: false });
expect(document.querySelector('[data-testid="person-add-event"]')).toBeNull();
});
});

View File

@@ -1,14 +1,50 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte';
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
import GroupingControl from '$lib/timeline/GroupingControl.svelte';
import { timelineMeta } from '$lib/timeline/timelineMeta';
import { filterTimeline } from '$lib/timeline/timelineFilter';
import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const meta = $derived(timelineMeta(data.timeline));
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
// Layer-filter state (#780). Layer hiding is client-side only — the whole
// timeline is loaded once by #779's SSR load and we derive a filtered view of
// it here; there is no goto, no URL param, and no extra fetch.
let personalOn = $state(true);
let historicalOn = $state(true);
let lettersOn = $state(true);
// Grouping state (#827) lives here beside the layer-filter state; the regroup is a
// pure client-side transform over the already-filtered view — filter-then-group.
let groupingMode = $state<GroupingMode>('date');
const filteredTimeline = $derived(
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
);
const filteredEmpty = $derived(
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
);
// The grouping control is only meaningful while loose letters remain in the filtered
// view; with the Letters layer off there is nothing to regroup, so it disables but
// keeps its selected mode (REQ-018).
const hasLetters = $derived(hasLooseLetters(filteredTimeline));
// Meta-line figures track the *filtered* view, so the header counts always
// match what is actually on screen once layers are toggled off (#780 — this
// closes the prior D1 limitation, where the counts stayed on the full timeline).
const meta = $derived(timelineMeta(filteredTimeline));
function resetFilters() {
personalOn = true;
historicalOn = true;
lettersOn = true;
}
// Compose the sub-line from segments joined by " · " so the range drops out
// cleanly when there are no year bands; the whole line is absent when the
// timeline is empty (REQ-002). Counts come from the route alone, never from
@@ -34,7 +70,13 @@ const metaLine = $derived.by(() => {
: m.timeline_events_count({ count: meta.eventCount })
);
}
segments.push(m.timeline_grouping_date());
segments.push(
groupingMode === 'event'
? m.timeline_grouping_event()
: groupingMode === 'thema'
? m.timeline_grouping_thema()
: m.timeline_grouping_date()
);
return segments.join(' · ');
});
</script>
@@ -48,10 +90,56 @@ const metaLine = $derived.by(() => {
border is intentionally omitted (the page is already bg-canvas), per the
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
<!-- Wrapping header so the CTA drops below the heading at narrow widths
(≤360px) instead of overflowing — #842 REQ-001. -->
<header class="flex flex-wrap items-center justify-between gap-3">
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
{#if data.canWrite}
<a
data-testid="timeline-add-event"
href="/zeitstrahl/events/new"
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.timeline_add_event()}
</a>
{/if}
</header>
{#if hasContent}
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
<p data-testid="timeline-meta" class="mt-1 mb-3 font-sans text-xs text-ink-3">{metaLine}</p>
<!-- Grouping toggle stacked above the #780 layer-filter trigger so the two read as
one control cluster in the header (REQ-010); the top-right corner stays the
add-event CTA. Disabled — but kept in place — when no loose letters remain
(REQ-018). -->
<div class="mb-3" data-testid="grouping-cluster">
<GroupingControl bind:mode={groupingMode} disabled={!hasLetters} />
</div>
<TimelineFilters
bind:personalOn={personalOn}
bind:historicalOn={historicalOn}
bind:lettersOn={lettersOn}
/>
{/if}
{#if hasContent && filteredEmpty}
<!-- Filtered-empty: a calm message + one-click reset below the still-open
filter bar — never a blank page, and never the generic "no events"
state (which would imply the archive itself is empty). REQ-006. -->
<div data-testid="timeline-filter-empty" class="py-12 text-center">
<p class="font-serif text-base text-ink-2">{m.timeline_filter_empty_state()}</p>
<button
type="button"
data-testid="timeline-filter-empty-reset"
onclick={resetFilters}
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
>
{m.timeline_filter_reset()}
</button>
</div>
{:else}
<TimelineView
timeline={filteredTimeline}
canWrite={data.canWrite}
groupingMode={groupingMode}
/>
{/if}
<TimelineView timeline={data.timeline} />
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import * as m from '$lib/paraglide/messages.js';
import Page from './+page.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
@@ -111,3 +112,214 @@ describe('/zeitstrahl page', () => {
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
});
});
describe('/zeitstrahl layer filter (#780)', () => {
const letter = (title: string, documentId: string) => makeEntry({ documentId, title });
const historical = (title: string) =>
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
eventId: 'h1',
documentId: undefined,
title,
senderName: '',
receiverName: ''
});
const personal = (title: string) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: 'p1',
documentId: undefined,
title,
senderName: '',
receiverName: ''
});
const mixed = () =>
makeTimelineDTO({
years: [
makeYear(1915, [
letter('Brief Eins', 'd1'),
historical('Erster Weltkrieg'),
personal('Umzug nach Berlin')
])
]
});
async function openBar() {
await page.getByTestId('timeline-filter-trigger').click();
}
it('hides letter cards when the Letters layer is off and restores them, with no fetch (REQ-005/002)', async () => {
render(Page, { data: pageData(mixed()) });
await expect.element(page.getByText('Brief Eins')).toBeVisible();
await openBar();
await page.getByTestId('timeline-filter-letters').click();
await expect.poll(() => page.getByText('Brief Eins').query()).toBeNull();
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
await page.getByTestId('timeline-filter-letters').click();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
});
it('hides historical event cards when the Historical layer is off (REQ-004)', async () => {
render(Page, { data: pageData(mixed()) });
await openBar();
await page.getByTestId('timeline-filter-historical').click();
await expect.poll(() => page.getByText('Erster Weltkrieg').query()).toBeNull();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
});
it('hides personal event cards when the Personal layer is off (REQ-003)', async () => {
render(Page, { data: pageData(mixed()) });
await openBar();
await page.getByTestId('timeline-filter-personal').click();
await expect.poll(() => page.getByText('Umzug nach Berlin').query()).toBeNull();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
await expect.element(page.getByText('Erster Weltkrieg')).toBeVisible();
});
it('shows the filtered-empty message + reset below the open bar when all layers are off (REQ-006)', async () => {
render(Page, { data: pageData(mixed()) });
await openBar();
await page.getByTestId('timeline-filter-personal').click();
await page.getByTestId('timeline-filter-historical').click();
await page.getByTestId('timeline-filter-letters').click();
await expect.element(page.getByText(m.timeline_filter_empty_state())).toBeVisible();
await expect.element(page.getByTestId('timeline-filter-empty-reset')).toBeVisible();
// the generic TimelineView empty state is never what shows for a filtered-empty view
expect(page.getByText(m.timeline_empty_state()).query()).toBeNull();
// the one-click reset restores every layer
await page.getByTestId('timeline-filter-empty-reset').click();
await expect.element(page.getByText('Brief Eins')).toBeVisible();
});
it('recomputes the meta-line counts from the filtered view, so a hidden layer drops out of the totals (#780, resolves D1)', async () => {
render(Page, { data: pageData(mixed()) });
const meta = page.getByTestId('timeline-meta');
// all layers on → the one letter and the two events are counted
await expect.element(meta).toHaveTextContent(m.timeline_letters_count_singular());
await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 }));
await openBar();
await page.getByTestId('timeline-filter-letters').click();
// the hidden letter leaves the count instead of lingering as "1 Brief";
// the event total is untouched
await expect.element(meta).not.toHaveTextContent(m.timeline_letters_count_singular());
await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 }));
});
});
describe('/zeitstrahl curator affordances (#842)', () => {
const curated = (eventId: string) =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
});
const withWrite = (timeline: ReturnType<typeof makeTimelineDTO>) => ({
...pageData(timeline),
canWrite: true
});
it('renders the add-event CTA in a wrapping header when the viewer can write (REQ-001)', () => {
render(Page, { data: withWrite(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] })) });
const add = document.querySelector(
'[data-testid="timeline-add-event"]'
) as HTMLAnchorElement | null;
expect(add).not.toBeNull();
expect(add?.getAttribute('href')).toBe('/zeitstrahl/events/new');
// The header wraps so the CTA drops below the heading at narrow widths (≤360px)
// rather than overflowing — REQ-001.
expect(add?.closest('header')?.classList.contains('flex-wrap')).toBe(true);
});
it('renders no add-event CTA when the viewer cannot write (REQ-002)', () => {
render(Page, {
data: pageData(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] }))
});
expect(document.querySelector('[data-testid="timeline-add-event"]')).toBeNull();
});
it('threads canWrite to the timeline so a curator sees an event edit link (REQ-001/009)', () => {
render(Page, {
data: withWrite(makeTimelineDTO({ years: [makeYear(1924, [curated('p9')])] }))
});
expect(document.querySelector('a[href="/zeitstrahl/events/p9/edit"]')).not.toBeNull();
});
it('shows no event edit link to a reader (REQ-007)', () => {
render(Page, {
data: pageData(makeTimelineDTO({ years: [makeYear(1924, [curated('p9')])] }))
});
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');
});
});