Compare commits

..

11 Commits

Author SHA1 Message Date
Marcel
fe1a3dcc00 refactor(frontend): share DateInputWithPrecision between life-date and relationship fields
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m29s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m22s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 28s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
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 17s
PersonLifeDateField and RelationshipDateField were the same DateInput + restricted
precision <select>: identical onMount seeding (incl. the YEAR fallback for stored
non-offered precisions), the setCustomValidity partial-date guard, and markup.
Extract that into a domain-agnostic DateInputWithPrecision primitive (caller injects
the precisions, labels, hint, and styling deltas); both fields become thin wrappers
that keep their existing public props, so the person new/edit pages and the Stammbaum
call sites are unchanged. Named to stay distinct from the full DatePrecisionField
(documents/timeline, all seven precisions + RANGE). The relationship select drops its
redundant sr-only label, keeping the equivalent aria-label. PersonLifeDateField,
AddRelationshipForm and RelationshipChip specs (26) stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:28:40 +02:00
Marcel
063d1aac55 refactor(frontend): share formatDatePart between life dates and relationships
formatLifeDate and the relationship formatEnd were the same nullable-date →
formatDocumentDate delegation (YEAR fallback, '' on null). Hoist that core into
formatDatePart in documentDate.ts; formatLifeDate becomes a thin glyph-free
alias and formatRelationshipDateRange calls the shared helper directly. The two
range composers stay separate — they genuinely differ (*/† glyphs vs leading
dash). relationshipDates, personLifeDates and documentDate specs (60) stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:21:21 +02:00
Marcel
a3fd886711 refactor(relationship): collapse add/update onto shared invariant helpers
addRelationship and updateRelationship each inlined the same self-check,
reverse-PARENT_OF cycle check, saveAndFlush→DUPLICATE conflict mapping, and
family-membership flagging. Extract them into requireNotSelf,
requireNoReverseParent, persistOrConflict, and flagFamilyMembership so the
shared invariants live once and each public method reads as its own clear
create-vs-mutate sequence. Behaviour-preserving: RelationshipServiceTest (22)
and RelationshipServiceIntegrationTest (10) stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:19:07 +02:00
Marcel
73a01b1cad refactor(person): extract shared DatePrecision coherence/normalize validator
PersonService and RelationshipService each carried a verbatim copy of the
date⇔precision coherence check and the null→UNKNOWN precision normalizer.
Hoist both into a single document.DatePrecisionValidation util so the rule
that the V76/V78 CHECK constraints mirror has one source of truth. Each
service keeps its own order check (BIRTH_AFTER_DEATH vs
INVALID_RELATIONSHIP_DATES), which is the only genuinely domain-specific part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:15:12 +02:00
Marcel
d9028da941 Merge remote-tracking branch 'origin/main' into feat/issue-837-relationship-edit-dates
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m2s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m8s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
SDD Gate / RTM Check (pull_request) Successful in 16s
# Conflicts:
#	.specify/rtm.md
2026-06-14 19:47:26 +02:00
Marcel
663bb57334 docs(relationship): ADR-044, DB diagrams, deploy runbook, RTM rows
All checks were successful
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 29s
SDD Gate / Constitution Impact (pull_request) Successful in 20s
CI / Unit & Component Tests (pull_request) Successful in 4m38s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m58s
- ADR-044 extends ADR-039 to the relationship edge: LocalDate+DatePrecision,
  update re-validation of create invariants, no @Version (last-write-wins),
  DELETE→404 anti-enumeration alignment, precise derived marriage date, and the
  relationshipDates.ts location reusing the existing person→shared boundary.
- db-orm.puml: person_relationships now carries from_date/from_date_precision/
  to_date/to_date_precision; db-relationships.puml gets a V78 columns-only note.
- DEPLOYMENT.md §5: V78 deploy note — no maintenance window, stop-old-then-start
  ordering (not rolling-deploy-safe), targeted pg_restore rollback.
- CLAUDE.md error-code list gains INVALID_RELATIONSHIP_DATES.
- rtm.md: REQ-001..REQ-019 for #837 mapped to impl + tests, all Done.

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:29:08 +02:00
Marcel
491d1a015a feat(relationship): date+precision edit UI, notes, and read-view display
Regenerate api.ts for the LocalDate+DatePrecision RelationshipDTO /
RelationshipUpsertRequest and the new PUT, then migrate every caller:

- RelationshipDateField (mirrors PersonLifeDateField: DAY/MONTH/YEAR, 44px
  targets, labelled, semantic dark-mode tokens, relation_* i18n keys).
- AddRelationshipForm is now upsert-capable: an optional `relationship` prop
  pre-fills type, person, both dates+precision and notes; posts to
  ?/updateRelationship (else ?/addRelationship); the submit control disables and
  shows a progress spinner while a request is in flight (REQ-019); notes textarea
  (<=2000).
- RelationshipChip gains an accessible Edit affordance (canWrite + onEdit);
  StammbaumCard wires it, formats the date range via formatRelationshipDateRange,
  and sorts by fromDate. PersonRelationshipsCard (read view) shows the date range
  and notes; no dates -> no date line.
- persons/[id]/edit/+page.server.ts: updateRelationship action (PUT) + the
  addRelationship action reshaped to date+precision+notes (empty date omits
  precision for coherence).
- Genealogy callers fixed for the dropped year fields: familyForest spouse-order
  and StammbaumConnectors ended-edge dashing now key off fromDate/toDate.
- i18n relation_* form keys in de/en/es.

REQ-004, REQ-014, REQ-015, REQ-016, REQ-019

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:24:24 +02:00
Marcel
4d9b165a2d feat(relationship): add formatRelationshipDateRange helper
Plain-text "from – to" range formatter for relationship dates, delegating all
precision rendering to the shared formatDocumentDate (zero new precision logic).
Lives in $lib/person next to personLifeDates and reuses the existing
person → shared boundary. From-only renders just the start (no trailing dash),
no dates renders nothing.

REQ-015

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:45:55 +02:00
Marcel
6d2b6f3d2b feat(relationship): add PUT update endpoint, align DELETE mismatch to 404
PUT /api/persons/{id}/relationships/{relId} (@RequirePermission WRITE_ALL)
updates a relationship's type, related person, dates and notes in place,
preserving the original createdAt. The update re-runs every create invariant —
self-relation (VALIDATION_ERROR), date coherence/order, reverse PARENT_OF
(CIRCULAR_RELATIONSHIP) and the (person, relatedPerson, type) unique constraint
via saveAndFlush (DUPLICATE_RELATIONSHIP) — and re-flags both endpoints as
family members when edited into a family type. The directed orientation is
preserved per viewpoint, so a PARENT_OF edge stays parent->child whether edited
from either person's page.

Ownership mismatch now returns 404 RELATIONSHIP_NOT_FOUND (shared loadOwned
helper) for both PUT and DELETE — anti-enumeration, replacing DELETE's former
403. No @Version: last-write-wins, matching person edit (single-writer archive).

REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-012, REQ-013, REQ-018

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:36:43 +02:00
Marcel
0f6e9f7bc7 feat(relationship): store from/to as LocalDate + DatePrecision
Replace person_relationships.from_year/to_year (Integer) with from_date/
to_date (LocalDate) + NOT-NULL from_date_precision/to_date_precision
(DatePrecision, default UNKNOWN), mirroring the Person life-date pattern
(ADR-039 / V76). V78 backfills existing years as YYYY-01-01 at YEAR precision,
adds five named CHECK constraints (coherence both ends, from<=to, precision
value sets) and drops the year columns — verified by RelationshipMigrationTest
on a real Postgres 16 container.

validateRelationshipDates replaces validateYears: coherence (date <=> non-
UNKNOWN precision) -> INVALID_DATE_PRECISION, order (toDate before fromDate) ->
INVALID_RELATIONSHIP_DATES. The derived Zeitstrahl Heirat event now sources the
SPOUSE_OF.from_date at its stored precision, so a DAY-precision wedding surfaces
the exact day instead of just the year. RelationshipDTO and the shared
create/update request (renamed CreateRelationshipRequest ->
RelationshipUpsertRequest) carry the date+precision fields.

REQ-001, REQ-002, REQ-003, REQ-010, REQ-011, REQ-014, REQ-017

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:28:27 +02:00
Marcel
47b1ad6199 feat(relationship): add INVALID_RELATIONSHIP_DATES error code
New 400 error code for a relationship whose toDate precedes its fromDate,
registered in all four sites at once (ErrorCode.java, errors.ts,
getErrorMessage, messages/{de,en,es}.json) per constitution §3.6. Thrown by
the relationship date validation introduced in the following commit.

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:19:07 +02:00
40 changed files with 126 additions and 2376 deletions

View File

@@ -192,52 +192,17 @@ 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) ---
# 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.
# 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.
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 ---
@@ -272,7 +237,8 @@ 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=$(api GET \
OPEN_ISSUES=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
MATCHED=$(echo "$OPEN_ISSUES" | jq \
@@ -289,10 +255,11 @@ jobs:
--arg run_url "$RUN_URL" \
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
api PATCH \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" \
curl -sf -X PATCH \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" > /dev/null
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
echo "Updated tracking issue #${ISSUE_NUMBER}"
else
# Closed prior issue that recurs → new issue (not reopened).
@@ -301,21 +268,24 @@ jobs:
--arg title "$MARKER" \
--arg body "$ISSUE_BODY" \
'{"title": $title, "body": $body}')
CREATED=$(api POST \
"${GITEA_URL}/api/v1/repos/${REPO}/issues" \
CREATED=$(curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
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=$(api GET \
LABEL_IDS=$(curl -sf \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
api POST \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" \
curl -sf -X POST \
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"labels\": $LABEL_IDS}" > /dev/null
-d "{\"labels\": $LABEL_IDS}" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
fi
exit "$AUDIT_EXIT"

View File

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

View File

@@ -28,13 +28,6 @@ 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 (#850). 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).
*/
@@ -54,7 +47,6 @@ public record TimelineEntryDTO(
DerivedEventType derivedType,
UUID rootTagId,
String rootTagName,
String rootTagColor,
UUID linkedEventId
String rootTagColor
) {
}

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,20 +80,13 @@ public class TimelineService {
// Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// Fetch curated events once; the events that survive the filter below feed both the
// event entries and the batched letter→event link pass (resolveLetterEventLinks), so the
// membership pass costs no extra query and touches only on-screen events. REQ-009.
List<TimelineEvent> allEvents = eventRepository.findAll();
// ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>();
List<TimelineEvent> filteredEvents = new ArrayList<>();
for (TimelineEvent ev : allEvents) {
for (TimelineEvent ev : eventRepository.findAll()) {
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
filteredEvents.add(ev);
entries.add(mapEvent(ev));
}
@@ -114,9 +107,8 @@ public class TimelineService {
letters.add(doc);
}
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, filteredEvents);
for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
entries.add(mapDocument(doc, rootByDocId));
}
return bucket(entries);
@@ -237,13 +229,11 @@ public class TimelineService {
null,
null,
null,
null,
null
);
}
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
Map<UUID, UUID> eventByDocId) {
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
RootTag root = rootByDocId.get(doc.getId());
return new TimelineEntryDTO(
Kind.LETTER,
@@ -261,50 +251,10 @@ public class TimelineService {
null,
root == null ? null : root.id(),
root == null ? null : root.name(),
root == null ? null : root.color(),
eventByDocId.get(doc.getId())
root == null ? null : root.color()
);
}
/**
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the
* event whose {@code documents} set contains the letter (REQ-009). A single doc→event map is
* 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 earliest-dated event wins (then lowest {@code eventId}); the pass runs over a
* stably-ordered copy so the link is a deterministic property of the data, not a coin-flip on
* the undefined repository iteration order ({@code putIfAbsent} keeps the first = winner). The
* map is built only over the events that survived the timeline filter, so the lazy
* {@code documents} collection is hydrated for on-screen events alone (finding #10); a letter
* whose only linking event was filtered out links to nothing, matching the frontend's
* filter-then-cluster (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
*/
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
if (letterDocIds.isEmpty()) return Map.of();
// Stable order so a multi-event letter links deterministically: earliest event date
// (undated last), then lowest id. putIfAbsent below then keeps the winner (REQ-009).
List<TimelineEvent> ordered = events.stream()
.sorted(Comparator
.comparing(TimelineEvent::getEventDate,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(TimelineEvent::getId))
.toList();
Map<UUID, UUID> eventByDocId = new HashMap<>();
for (TimelineEvent ev : ordered) {
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,106 +511,6 @@ class TimelineServiceTest {
verify(tagService, times(1)).resolveRootTags(anyList());
}
// ─── letter→event link (#850, REQ-009) ───────────────────────────────────
@Test
void letter_in_a_curated_events_documents_carries_that_events_id() {
// REQ-009: 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-009: a letter referenced by no curated event → linkedEventId null; the frontend
// then renders it as a loose chronological letter (REQ-006).
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();
}
@Test
void multi_event_letter_links_deterministically_to_the_earliest_event() {
// REQ-009: a document referenced by >1 curated event links to the earliest-dated event
// (then lowest id), independent of repository iteration order — not a coin-flip on
// findAll()'s undefined order.
Document shared = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
TimelineEvent earlier = TimelineEvent.builder()
.id(UUID.fromString("00000000-0000-0000-0000-000000000001"))
.title("Frühes Ereignis").type(EventType.PERSONAL)
.eventDate(LocalDate.of(1913, 3, 1)).precision(DatePrecision.MONTH)
.documents(new HashSet<>(Set.of(shared)))
.build();
TimelineEvent later = TimelineEvent.builder()
.id(UUID.fromString("00000000-0000-0000-0000-000000000002"))
.title("Spätes Ereignis").type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 3, 1)).precision(DatePrecision.MONTH)
.documents(new HashSet<>(Set.of(shared)))
.build();
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(shared));
// `later` first in iteration: a naive putIfAbsent would wrongly pick it.
when(eventRepository.findAll()).thenReturn(List.of(later, earlier));
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
.isEqualTo(earlier.getId());
// Reversed order yields the same winner — the link is order-independent.
when(eventRepository.findAll()).thenReturn(List.of(earlier, later));
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
.isEqualTo(earlier.getId());
}
@Test
void letter_linked_only_to_a_filtered_out_event_has_null_linkedEventId() {
// finding #10: the link pass runs over the events that survived the filter, not all of
// them. A letter whose only linking event is excluded by the active filter links to
// nothing (the frontend would drop it loose anyway) — and the lazy `documents` collection
// is never hydrated for events that are off-screen.
Document letterDoc = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
TimelineEvent worldEvent = TimelineEvent.builder().id(UUID.randomUUID())
.title("Somme").type(EventType.HISTORICAL)
.documents(new HashSet<>(Set.of(letterDoc)))
.build();
when(eventRepository.findAll()).thenReturn(List.of(worldEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
// Filter to PERSONAL only → the HISTORICAL event is filtered out of the view.
TimelineEntryDTO entry = theLetter(timelineService.assemble(
new TimelineFilter(null, null, EventType.PERSONAL, null, null)));
assertThat(entry.linkedEventId()).isNull();
}
private static TimelineEntryDTO theLetter(TimelineDTO result) {
return java.util.stream.Stream.concat(
result.years().stream().flatMap(y -> y.entries().stream()),
result.undated().stream())
.filter(e -> e.kind() == Kind.LETTER)
.findFirst().orElseThrow();
}
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0);
@@ -623,7 +523,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

@@ -1,93 +0,0 @@
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

@@ -188,7 +188,6 @@
"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",
@@ -1036,7 +1035,6 @@
"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",
@@ -1049,26 +1047,16 @@
"timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"timeline_grouping_date": "Gruppierung: Datum",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
"timeline_bucket_show_less": "Weniger anzeigen",
"timeline_letter_glyph_label": "Brief",
"timeline_cluster_letter_count": "{count} Briefe",
"timeline_tag_chip_label": "Thema",
"timeline_layer_historical_suffix": "historisch",
"timeline_strip_density_caption": "Monats-Dichte",
"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,7 +188,6 @@
"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",
@@ -1036,7 +1035,6 @@
"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",
@@ -1049,26 +1047,16 @@
"timeline_derived_birth": "Birth",
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"timeline_grouping_date": "Grouping: Date",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_bucket_show_more": "+ {count} more letters",
"timeline_bucket_show_less": "Show fewer",
"timeline_letter_glyph_label": "Letter",
"timeline_cluster_letter_count": "{count} letters",
"timeline_tag_chip_label": "Topic",
"timeline_layer_historical_suffix": "historical",
"timeline_strip_density_caption": "Monthly density",
"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,7 +188,6 @@
"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",
@@ -1036,7 +1035,6 @@
"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",
@@ -1049,26 +1047,16 @@
"timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"timeline_grouping_date": "Agrupación: Fecha",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_bucket_show_more": "+ {count} cartas más",
"timeline_bucket_show_less": "Mostrar menos",
"timeline_letter_glyph_label": "Carta",
"timeline_cluster_letter_count": "{count} cartas",
"timeline_tag_chip_label": "Tema",
"timeline_layer_historical_suffix": "histórico",
"timeline_strip_density_caption": "Densidad mensual",
"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,8 +2467,6 @@ export interface components {
rootTagId?: string;
rootTagName?: string;
rootTagColor?: string;
/** Format: uuid */
linkedEventId?: string;
};
TimelineYearDTO: {
/** Format: int32 */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,33 +11,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* A single archive letter on the timeline: sender → receiver, title, and a
* 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-010); never the raw-HTML directive.
*
* Inside an event cluster the card sits in the contained event card and renders as
* the `.lcard.ev` `compact` variant (#850, REQ-002): tighter row, and the redundant
* date chip is dropped when the title already embeds the date. The per-letter tag
* chip can be suppressed via `suppressTagChip` for callers that already convey it.
* `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
*/
let {
entry,
variant = 'plain',
suppressTagChip = false,
compact = false
}: {
entry: TimelineEntryDTO;
variant?: 'plain' | 'event';
suppressTagChip?: boolean;
compact?: boolean;
} = $props();
let { entry }: { entry: TimelineEntryDTO } = $props();
const isEventVariant = $derived(variant === 'event');
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Inside an event card the band frames the time, so a compact in-card letter drops the
// redundant date chip — but ONLY when the (free-form OCR) title actually embeds the formatted
// date, e.g. "H-0023 6. Juli 1916". A title without the date keeps its chip, so a letter like
// "Brief an Mutter" never loses its month/day (the band frames only the year) — #850, finding #4.
const titleEmbedsDate = $derived(!!dateLabel && !!entry.title && entry.title.includes(dateLabel));
const showDate = $derived(!compact || !titleEmbedsDate);
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
@@ -50,37 +28,28 @@ const receiver = $derived(
<a
href="/documents/{entry.documentId}"
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
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}
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"
>
{#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 font-bold break-words text-ink"
class:text-sm={!compact}
class:text-xs={compact}
>
<span class="font-serif text-sm font-bold break-words text-ink">
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
<span class="whitespace-pre-line">{entry.title}</span>
</span>
{/if}
<span class="font-sans text-xs break-words text-ink-3" class:mt-0.5={!compact}>
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
<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 && showDate}
{#if dateLabel}
<span data-testid="letter-date"> · {dateLabel}</span>
{/if}
</span>
{#if entry.rootTagName && !suppressTagChip}
{#if entry.rootTagName}
<!-- The primary root-tag chip sits on its own line beneath the meta line
(#835 §3); absent when the letter has no tag (REQ-006), and suppressed when
the caller already conveys the topic (suppressTagChip). -->
(#835 §3); absent when the letter has no tag (REQ-005). -->
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
{/if}
</a>

View File

@@ -127,58 +127,3 @@ describe('LetterCard', () => {
expect(chip?.textContent).toContain('Familie');
});
});
describe('LetterCard — event-cluster variants (#850, REQ-002)', () => {
it('carries the .lcard.ev class in the event variant (REQ-002)', () => {
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-006)', () => {
render(LetterCard, { entry: makeEntry() });
expect(document.querySelector('a.ev')).toBeNull();
});
it('suppresses the per-letter tag chip when asked, even with a root tag', () => {
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', () => {
render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) });
expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull();
});
it('drops the compact date chip only when the title actually embeds the formatted date (#850)', () => {
// An archive title like "H-0023 6. Juli 1916" already carries the date, so inside an
// event card (where the band frames the time) the redundant chip is dropped.
const entry = makeEntry({ eventDate: '1916-07-06', precision: 'DAY' });
const dateLabel = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
render(LetterCard, { entry: { ...entry, title: `H-0023 ${dateLabel}` }, compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).toBeNull();
expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown
});
it('keeps the compact date chip when the title does NOT embed the date (#850, finding #4)', () => {
// Titles are free-form OCR text — a titled letter whose title carries no date must keep
// its month/day, since inside an event card the band frames only the year.
render(LetterCard, {
entry: makeEntry({ eventDate: '1916-07-06', precision: 'DAY', title: 'Brief an Mutter' }),
compact: true
});
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
});
it('keeps the date in the compact variant when the letter has no title (#850)', () => {
render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true });
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
});
it('renders the compact variant on a single tighter row (#850)', () => {
render(LetterCard, { entry: makeEntry(), compact: true });
expect(document.querySelector('a.lcard.compact')).not.toBeNull();
});
});

View File

@@ -1,133 +0,0 @@
<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

@@ -1,74 +0,0 @@
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,7 +6,6 @@ import LetterCard from './LetterCard.svelte';
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import { entryKey } from './entryKey';
import { buildEventLookup } from './eventClustering';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
@@ -19,19 +18,8 @@ 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.
*
* The event lookup is built once over the whole (already layer-filtered) timeline
* and threaded to every band so a curated event's letters cluster under it inline
* (#850, REQ-002). The undated bucket stays plain (events as pills, letters as
* cards) — out of clustering scope.
*/
let {
timeline,
personId = undefined,
canWrite = false
}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
const eventLookup = $derived(buildEventLookup(timeline));
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
@@ -62,7 +50,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
<li>
{#if row.t === 'band'}
<YearBand year={row.year} canWrite={canWrite} eventLookup={eventLookup} />
<YearBand year={row.year} />
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}
@@ -87,9 +75,9 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
<li>
{#if entry.kind === 'EVENT'}
{#if entry.type === 'HISTORICAL'}
<WorldBand entry={entry} canWrite={canWrite} />
<WorldBand entry={entry} />
{:else}
<EventPill entry={entry} canWrite={canWrite} />
<EventPill entry={entry} />
{/if}
{:else}
<LetterCard entry={entry} />

View File

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

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
@@ -11,11 +11,9 @@ 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). 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.
* uses text-ink-2 to stay AA in both themes (REQ-019).
*/
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
@@ -26,9 +24,6 @@ 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 canEditEvent's derived check is a
// no-op here — the gate is the curator flag plus a real eventId (#842 REQ-006/008).
const canEdit = $derived(canEditEvent(entry, canWrite));
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
@@ -51,14 +46,4 @@ const canEdit = $derived(canEditEvent(entry, canWrite));
<!-- 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,35 +73,4 @@ 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,10 +3,8 @@ import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import EventCluster from './EventCluster.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import { splitYearLetters, type EventCluster as EventClusterModel } from './eventClustering';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
@@ -14,113 +12,37 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
* render in DTO order as pills/world-bands; entries are never re-sorted (REQ-003).
*
* A curated event with letters linked to it (#850) becomes a contained event card:
* the event IS the card header and its linked letters sit inside (no separate pill —
* REQ-002). A curated event with letters in another year band renders here as a
* cross-year text-header card (REQ-004). An event with no linked letters stays a
* plain pill/world-band (REQ-005).
*
* Every other letter (no linkedEventId, or linking to an event the #780 layer filter
* removed) stays loose: alternating left/right while the band holds ≤ 12 such loose
* letters (REQ-006), folding into a single month-density strip above that (REQ-007).
* The loose-letter layout and the strip count ONLY these loose letters — clustered
* letters never re-appear loose (REQ-007).
* 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).
*/
let {
year,
canWrite = false,
eventLookup
}: {
year: TimelineYearDTO;
canWrite?: boolean;
eventLookup?: Map<string, string>;
} = $props();
let { year }: { year: TimelineYearDTO } = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
| { t: 'eventcard'; event?: TimelineEntryDTO; cluster: EventClusterModel }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' };
// Split this band's letters into event clusters and the loose remainder once; the loose
// list alone drives the alternating layout and the density strip (REQ-007).
const split = $derived(
splitYearLetters(
year.entries.filter((e) => e.kind === 'LETTER'),
eventLookup
)
);
const loose = $derived(split.loose);
const dense = $derived(isDense(loose.length));
// Clusters keyed by eventId (built once in splitYearLetters): row assembly looks a letter's
// disposition up in O(1) — `byEvent.has(linkedEventId)` — instead of scanning the loose array
// per letter (was O(L²) on a dense band), and resolves an event's card with `byEvent.get`.
const byEvent = $derived(split.byEvent);
// Event ids that have a same-year EVENT entry in THIS band: those clusters render as that
// event's header (at the EVENT position); every other cluster is cross-year (REQ-004/015).
const sameYearEventIds = $derived.by<Record<string, true>>(() => {
const ids: Record<string, true> = {};
for (const entry of year.entries) {
if (entry.kind === 'EVENT' && entry.eventId) ids[entry.eventId] = true;
}
return ids;
});
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
const dense = $derived(isDense(letters.length));
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
const emitted: Record<string, true> = {};
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) {
if (entry.kind === 'EVENT') {
// A curated event whose letters live in THIS band becomes the contained card's
// header — its title reads once, no separate pill (REQ-002). Otherwise it stays a
// plain pill/world-band (REQ-005).
const cluster = entry.eventId ? byEvent.get(entry.eventId) : undefined;
if (cluster) {
out.push({ t: 'eventcard', event: entry, cluster });
emitted[cluster.eventId] = true;
} else {
out.push({ t: 'event', entry });
}
continue;
}
const cluster = entry.linkedEventId ? byEvent.get(entry.linkedEventId) : undefined;
if (!cluster) {
// A loose letter (not clustered): alternate while sparse, or fold the whole loose set
// into one density strip (inserted once, at the first loose letter) when dense.
if (!dense) {
} else if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
}
continue;
}
// A clustered letter. A same-year cluster is emitted at its EVENT entry, so skip it here.
// A cross-year cluster has no EVENT anchor in this band — emit its ✉ card HERE, at the
// position of its earliest linked letter, so the band stays in strict time order (REQ-015).
if (!sameYearEventIds[cluster.eventId] && !emitted[cluster.eventId]) {
out.push({ t: 'eventcard', cluster });
emitted[cluster.eventId] = true;
}
}
return out;
});
function rowKey(row: Row): string {
if (row.t === 'strip') return `strip-${year.year}`;
if (row.t === 'eventcard') return `evcard:${row.cluster.eventId}`;
return entryKey(row.entry);
}
</script>
<section class="py-2">
@@ -134,27 +56,20 @@ function rowKey(row: Row): string {
</h2>
<div class="mt-3 space-y-3">
{#each rows as row (rowKey(row))}
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
{#if row.t === 'event'}
{#if row.entry.type === 'HISTORICAL'}
<WorldBand entry={row.entry} canWrite={canWrite} />
<WorldBand entry={row.entry} />
{:else}
<EventPill entry={row.entry} canWrite={canWrite} />
<EventPill entry={row.entry} />
{/if}
{:else if row.t === 'eventcard'}
<EventCluster
letters={row.cluster.letters}
event={row.event}
title={row.cluster.title}
canWrite={canWrite}
/>
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
<LetterCard entry={row.entry} />
</div>
{:else}
<YearLetterStrip letters={loose} year={year.year} />
<YearLetterStrip letters={letters} year={year.year} />
{/if}
{/each}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
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-010 / CWE-79: inline event clustering renders curator event titles and import-derived
* letter titles + sender/receiver text through every component under lib/timeline (the reused
* LetterCard, the new EventCluster card, the existing pills/bands/strip). That 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-010)', () => {
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

@@ -1,151 +0,0 @@
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

@@ -1,63 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -128,8 +128,7 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
</div>
{/if}
<!-- Curator actions — full width, outlined. Both links are gated to
WRITE_ALL; the gate is UX only (the #781 route guard is the boundary). -->
<!-- Edit button — full width, outlined -->
{#if canWrite}
<a
href="/persons/{person.id}/edit"
@@ -143,15 +142,6 @@ 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

@@ -1,32 +0,0 @@
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,40 +1,14 @@
<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 { timelineMeta } from '$lib/timeline/timelineMeta';
import { filterTimeline } from '$lib/timeline/timelineFilter';
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);
const filteredTimeline = $derived(
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
);
const filteredEmpty = $derived(
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
);
// 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
@@ -60,7 +34,7 @@ const metaLine = $derived.by(() => {
: m.timeline_events_count({ count: meta.eventCount })
);
}
// REQ-011: the toggle-free chronological view carries no grouping segment.
segments.push(m.timeline_grouping_date());
return segments.join(' · ');
});
</script>
@@ -74,45 +48,10 @@ 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">
<!-- 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>
<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} />
{/if}
<TimelineView timeline={data.timeline} />
</div>
</div>

View File

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