Compare commits

...

38 Commits

Author SHA1 Message Date
Marcel
d3f93c556a docs(rtm): REQ-024 now localized per locale, point at messages.spec pin
Some checks failed
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m10s
nightly / deploy-staging (push) Successful in 5m8s
CI / Unit & Component Tests (pull_request) Successful in 3m42s
nightly / npm-audit (push) Failing after 17s
CI / OCR Service Tests (pull_request) Successful in 23s
Renovate / renovate (push) Failing after 33s
CI / Backend Unit Tests (pull_request) Successful in 4m43s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
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 21s
CI / Unit & Component Tests (push) Successful in 4m30s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:30:47 +02:00
Marcel
ce1b4c748e test(i18n): pin localized timeline layer/derived labels per locale
REQ-024 was updated (issue #779) to require localized sr-only/aria
labels instead of German-only. Pin the de/en/es values so they cannot
silently drift back to the German source strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:30:02 +02:00
Marcel
4a6fd770d7 fix(i18n): translate timeline sr-only labels in en/es locales
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m18s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m17s
CI / fail2ban Regex (pull_request) Successful in 47s
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 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
timeline_layer_* and timeline_derived_* shipped German values in the
English and Spanish catalogs, so EN/ES screen-reader users heard German
for the world/family layer and birth/death/marriage cues. Translate them;
de.json stays canonical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:34:54 +02:00
Marcel
732651959e fix(timeline): render undated events as pills/bands, not letter cards
The undated bucket is assembled from all entries, so it can contain
events as well as letters. Rendering every undated entry with LetterCard
produced a dead /documents/undefined link and "Unknown -> Unknown" for
events. Dispatch on kind/type like YearBand does (WorldBand/EventPill/
LetterCard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:32:32 +02:00
Marcel
7902f4e6ac refactor(timeline): extract entryKey helper from YearBand
Move the per-entry {#each} key logic into a shared entryKey.ts so the
undated bucket in TimelineView can reuse it. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:30:59 +02:00
Marcel
fee519b8a9 docs(timeline): document /zeitstrahl, lib/timeline, monthBuckets move; RTM #779
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m28s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 6m25s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
Route tables (CLAUDE.md + frontend/CLAUDE.md), the document/timeline.ts ->
$lib/shared/utils/monthBuckets move (document + shared READMEs), GLOSSARY
Lebensweg entry, the c4 l3-frontend people-stories diagram, and the RTM rows
REQ-001..027 for feature zeitstrahl-global-view (#779), all marked Done.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:00:00 +02:00
Marcel
b501592156 docs(timeline): reword LetterCard comment so the REQ-021 @html grep is zero
Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:59:41 +02:00
Marcel
852fb71ee7 test(timeline): add /zeitstrahl E2E spec
Nav-link smoke + timeline-in-<main> (empty-or-populated), and the 320px
no-overflow guarantee on a timeline seeded with 25+char correspondent names
(REQ-005). Runs against the real stack via the seeded admin session.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:54:07 +02:00
Marcel
6f32299255 feat(timeline): add /zeitstrahl route, SSR load, and nav link
SSR-first load fetches GET /api/timeline via createApiClient (auth cookie
forwarded), no query params for the global view (REQ-001), returns { timeline }
with no client-side fetch (REQ-002); 401 -> /login, any other non-ok ->
error(status, getErrorMessage(...)), never raw JSON, no PII logged (REQ-022).
The page renders <TimelineView> under the layout's <main>. Adds the Zeitstrahl
nav link (desktop + mobile) and 'timeline' to the eslint routes boundary
allow-list so the route may import the domain.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:50:28 +02:00
Marcel
dbef0e1e60 fix(timeline): wrap long letter names/titles to avoid 320px overflow
break-words on sender/receiver/title so a 25+char correspondent name cannot
force horizontal overflow on a 320px phone (REQ-005).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:44:23 +02:00
Marcel
588314f862 feat(timeline): add TimelineView orchestrator
Renders year bands in DTO order with interior empty-year runs folded into one
GapSpan (REQ-015), a single <ol> in chronological DOM order (REQ-006), the undated
bucket via {#if} (REQ-016), and a calm empty state (REQ-017). personId is a
declared seam (issue #10), undefined here, never passed to leaf cards (REQ-025).
Centered desktop spine / left phone spine via scoped CSS. Owns no <main>.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:44:05 +02:00
Marcel
f9ddcf0374 feat(timeline): add YearBand (section + sticky h2, cards vs strip)
One <section> per year with a sticky <h2> at top:4rem (REQ-006). Events render in
DTO order as pills/bands; letters render as individual cards while <= 12 (REQ-011)
or collapse to one density strip above that (REQ-012); DTO order is never re-sorted
(REQ-003). Letters carry an alternating data-side for the centered desktop axis
(REQ-004); single left column on phone (REQ-005). Derived-safe {#each} key.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:43:47 +02:00
Marcel
5bff428954 feat(timeline): add YearLetterStrip for dense years
Letter count + 12-month density sparkline + a >=44px keyboard-focusable expand
toggle that reveals that year's LetterCards (REQ-012). Sparkline values from the
shared monthHistogram.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:57 +02:00
Marcel
bea0e0d056 feat(timeline): add GapSpan for folded empty-year runs
A thin dashed span rendering '{from}–{to} · keine Einträge', collapsing to a
single year when the run has length 1 (REQ-015).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:39 +02:00
Marcel
e75448ba14 feat(timeline): add WorldBand for HISTORICAL context bands
Full-width muted band; RANGE renders a span pill (1914–1918) with a Zeitraum
aria-label (REQ-009); a RANGE with no end degrades to the start year, no pill,
no crash (REQ-010). World glyph is a redundant non-color cue with sr-only label
(REQ-018); text uses text-ink-2 to hold AA in both themes (REQ-019).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:21 +02:00
Marcel
b031f2736b feat(timeline): add EventPill for derived + curated event pills
Centered axis pill: derived life-events (* Geburt / † Tod / ⚭ Heirat) and curated
PERSONAL events (★, mint border) via getAccentConfig. Glyph wrapped aria-hidden +
sr-only label (REQ-018). Edit affordance only for a curated event with eventId,
never derived/null (REQ-008). REQ-007.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:36:03 +02:00
Marcel
e25001f7c9 feat(timeline): add LetterCard component
Single archive letter: sender → receiver (Unbekannt fallback for empty names,
REQ-014), title, precision date chip via timelineDateLabel (omitted when null,
REQ-013), linking to exactly /documents/{documentId} with no target (REQ-023).
44px touch target enforced inline + focus-visible ring (REQ-020). OCR/import
text via {...} escaping + whitespace-pre-line, no {@html} (REQ-021).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:30:55 +02:00
Marcel
6a35e8510b feat(timeline): add eventCardConfig accent matrix + DTO test factories
getAccentConfig(entry) maps each EVENT to its glyph (* / † / ⚭ / ★ / ◍), German
redundant-cue label, and accent kind (REQ-007/008/018). test-factories build
TimelineEntryDTO/TimelineDTO mirroring the real wire shape for component specs.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:26:29 +02:00
Marcel
607112afc2 feat(shared): add Sparkline primitive for fixed-series density bars
A minimal presentational bar series (one bar per value, heights scaled to the
max, faint floor for empty buckets). Lives in shared so both the timeline
density strip and the document chart can use it. REQ-012 (supports).

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:26:11 +02:00
Marcel
4e119f098d feat(timeline): add timeline i18n keys (de/en/es)
14 Paraglide keys for the /zeitstrahl view: nav link, heading, empty/undated/
gap/unknown-person chrome, letters count, strip expand, range aria, and the
layer/derived labels. The layer (Weltgeschehen/Familie) and derived (Geburt/
Tod/Heirat) labels carry the German term across all locales by design
(documented MVP decision). REQ-024.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:25:43 +02:00
Marcel
f34d42a09f feat(timeline): add timelineDensity helper (isDense, monthHistogram)
isDense(count) thresholds dense year bands at >12 letters (REQ-012);
monthHistogram(letters, year) buckets a band's letters into exactly 12 month
buckets via the shared fillDensityGaps, counting each letter on its eventDate
anchor month and ignoring undated entries (REQ-027). Imports shared only.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:20:37 +02:00
Marcel
1dc3b91458 refactor(timeline): move pure month-bucket math to $lib/shared/utils/monthBuckets
Relocate the 10 pure helpers (monthBoundaryFrom/To, buildMonthSequence,
fillDensityGaps, clipBucketsToRange, aggregateToYears, selectionBoundaryFrom/To,
tickIndicesFor, formatTickLabel) and their unit tests out of document/timeline.ts
into a shared module so lib/timeline/ can consume them without importing
lib/document/. The /api/documents/density glue (buildDensityUrl, fetchDensity,
DensityState, DensityFilters) stays in document/timeline.ts. Re-point the three
density components and the density-filter spec at the shared module.

Refs #779
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:18:50 +02:00
Marcel
1348255ae3 docs(timeline): update TimelineEntryDTO domain model table in CLAUDE.md
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m25s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 5m27s
CI / fail2ban Regex (push) Successful in 53s
CI / Semgrep Security Scan (push) Successful in 26s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / Unit & Component Tests (pull_request) Successful in 6m30s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Successful in 6m15s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
The previous entry referenced fields (id: String, primaryPersonName,
relatedPersonName) from an earlier design that was superseded during
spec review. Replace with the actual 13-field record shape implemented
in PR #826.

Fixes: @markus stale CLAUDE.md entry on PR #826

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:24:01 +02:00
Marcel
590b00d2d7 fix(timeline): add @Transactional(readOnly=true) to TimelineService.assemble()
Without the annotation, Hibernate closes its sub-transaction after
eventRepository.findAll() returns, leaving TimelineEvent entities
detached. Accessing ev.getPersons() or doc.getReceivers() on those
detached entities throws LazyInitializationException in production
(constitution §1.6). @DataJpaTest and @Transactional test classes
masked the bug by keeping an outer session alive.

Fixes: @felix / @markus review blockers on PR #826

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:23:10 +02:00
Marcel
1de314f49b docs(timeline): RTM, CLAUDE.md, and C4 updates for #777 assembly endpoint
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m42s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
- Add 20 REQ-NNN rows for issue #777 (all Done) to .specify/rtm.md
- Update CLAUDE.md timeline package description with TimelineService/TimelineController
- Extend l3-backend-timeline.puml with TimelineService/TimelineController components
  and their edges to PersonService and DocumentService

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:46:24 +02:00
Marcel
5017d17b11 chore(api): regenerate TypeScript types for GET /api/timeline
Adds TimelineDTO, TimelineYearDTO, TimelineEntryDTO with kind union
("EVENT"|"LETTER"), eventId, documentId, senderName, receiverName,
linkedPersonIds, derivedType fields.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:43:24 +02:00
Marcel
3a174dd91b test(timeline): add integration tests for TimelineService + findByGeneration
Verifies PersonRepository.findByGeneration handles match, no-match (empty
list not NPE), and null-generation persons (excluded). Also confirms
TimelineService.assemble() returns a persisted curated event in the
correct year band against real Postgres via Testcontainers.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:24:34 +02:00
Marcel
afd1f0b86b feat(timeline): add GET /api/timeline endpoint + 8-test controller suite
TimelineController exposes GET /api/timeline with @RequirePermission(READ_ALL)
and @Validated so @Min(0) on generation fires a 400. Delegates to
TimelineService.assemble(TimelineFilter). DomainException 404/400 propagate
via GlobalExceptionHandler (no extra mapping needed).

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:22:44 +02:00
Marcel
f08b09faeb feat(timeline): add TimelineService assembly + 24-test Mockito suite
Creates TimelineService.assemble(TimelineFilter): merges curated events
(TimelineEventRepository), derived life-events (assembleDerivedEvents()),
and archive letters (DocumentService) into a year-bucketed TimelineDTO.
WITHIN_BAND_ORDER Comparator tested standalone before assembly tests.
ArchUnit Rule 2 entry for ..timeline.. domain added in same commit.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:20:54 +02:00
Marcel
de30f66a2d feat(timeline): add PersonService.getPersonsByGeneration + DocumentService.getAllForTimeline
PersonRepository.findByGeneration(Integer) — boxed to match nullable entity field.
DocumentRepository.findAllForTimeline() — Document.list entity-graph, single query.
Both services delegate with one-liner methods.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:06:03 +02:00
Marcel
184fc9814a refactor(timeline): adapt TimelineEntryDTO to unified #777 shape
Replace the #776 DTO (primary/relatedPersonName + synthetic String id)
with the full #777 spec: kind, senderName, receiverName, eventId,
documentId, linkedPersonIds, title, eventDateEnd. Derived events now use
title=displayName, linkedPersonIds=[UUID...], eventId=null.

DerivedEventsAssemblyTest updated — all 16 tests pass.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:03:00 +02:00
Marcel
6b593a7bc6 docs(timeline): add derived-event glossary entries and update C4 diagram
All checks were successful
CI / Compose Bucket Idempotency (pull_request) Successful in 1m11s
SDD Gate / RTM Check (pull_request) Successful in 19s
SDD Gate / Contract Validate (pull_request) Successful in 32s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 3m33s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 4m42s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / fail2ban Regex (push) Successful in 49s
CI / Unit & Component Tests (pull_request) Successful in 6m13s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Successful in 5m59s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
Add GLOSSARY.md entries for derived event, DerivedEventType, derivedType,
and assembleDerivedEvents() to cover the vocabulary introduced by #776.
Update l3-backend-timeline.puml: remove stale "planned, #775" labels,
add Rel from TimelineEventService to personDomain for assembleDerivedEvents
batch-fetch calls, document the on-read strategy in the component notes.

Refs #776
Co-Authored-By: claude-sonnet-4-6 <noreply@anthropic.com>
2026-06-13 14:53:50 +02:00
Marcel
033001559d docs(timeline): update RTM and CLAUDE.md for issue #776
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 5m56s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Successful in 6m26s
SDD Gate / RTM Check (pull_request) Has been cancelled
SDD Gate / Contract Validate (pull_request) Has been cancelled
SDD Gate / Constitution Impact (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 27s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m13s
RTM: add REQ-001–REQ-016 rows with Done status, implementation files, and test IDs.
CLAUDE.md: expand timeline package entry with TimelineEntryDTO, DerivedEventType,
and assembleDerivedEvents(); add TimelineEntryDTO to domain model table.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:37:19 +02:00
Marcel
c66d83cfc6 feat(timeline): implement assembleDerivedEvents() with TDD (REQ-001–REQ-016)
Adds RelationshipService dependency to TimelineEventService and implements:
- assembleDerivedEvents() — public @Transactional(readOnly=true) orchestrator
- buildBirthEvents() — Person.birthDate → BIRTH events with precision pass-through
- buildDeathEvents() — Person.deathDate → DEATH events with precision pass-through
- buildMarriageEvents() — SPOUSE_OF edges → MARRIAGE events, dedup on row id

Synthetic prefixed ids (birth:/death:/marriage:) are structurally non-UUID.
Null fromYear marriages are emitted with eventDate=null + precision=UNKNOWN (REQ-006).
Non-family-member persons excluded from birth/death; SPOUSE_OF edges always emit (REQ-013).

All 16 tests in DerivedEventsAssemblyTest pass.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:35:50 +02:00
Marcel
7810ca7dd7 feat(relationship): add findAllSpouseEdges() for timeline assembly
Returns all SPOUSE_OF edges with JOIN FETCH on both person sides,
preventing N+1 in TimelineService.assembleDerivedEvents() (REQ-011).
Reuses existing findAllByRelationTypeIn query which already JOIN FETCHes.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:30:30 +02:00
Marcel
4245b821b9 feat(timeline): add DerivedEventType enum and TimelineEntryDTO record
DerivedEventType: BIRTH / DEATH / MARRIAGE discriminator for derived events.
TimelineEntryDTO: unified String-id DTO for both curated and derived events;
id is String (not UUID) to accommodate synthetic prefixed ids (birth:/death:/marriage:).

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:29:36 +02:00
Marcel
663ffad49b docs(adr): add ADR-043 — derived person life-events on-read strategy (Proposed)
Covers three pre-implementation decisions for issue #776:
1. On-read assembly, never persisted (no migration)
2. Synthetic prefixed String ids (birth:/death:/marriage:)
3. assembleDerivedEvents() as the public cross-issue contract on TimelineService

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:28:48 +02:00
Marcel
b05990fffb docs(adr): renumber SDD adoption ADR 041 -> 042 (collision with renovate ADR)
All checks were successful
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m48s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
CI / Unit & Component Tests (push) Successful in 4m58s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m51s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m9s
CI / Unit & Component Tests (pull_request) Successful in 3m36s
Two ADR-041 files landed on main in parallel (sdd-adoption and
renovate-runner-setup). Renames the SDD one to 042 and repoints its references
(SPEC_DRIVEN_DEVELOPMENT, constitution, .specify/adrs/README, sdd-gate.yml).
The renovate ADR keeps 041; its references are left untouched. Riding this PR
per request.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:38:12 +02:00
77 changed files with 4084 additions and 464 deletions

View File

@@ -3,7 +3,7 @@ name: SDD Gate
# Spec-Driven Development quality gate. Runs on PRs. # Spec-Driven Development quality gate. Runs on PRs.
# #
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed # This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
# spec.md (see ADR-041). So CI cannot lint the spec text itself — instead it validates the SDD # spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution. # artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
# #
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the # The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
@@ -11,7 +11,7 @@ name: SDD Gate
# #
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`) # TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
# once SDD adoption has settled — target: after the first 5 features have shipped through # once SDD adoption has settled — target: after the first 5 features have shipped through
# the workflow. Tracked in ADR-041. # the workflow. Tracked in ADR-042.
on: on:
pull_request: pull_request:

View File

@@ -10,7 +10,7 @@ This project already keeps a mature, permanent ADR archive at
next free `NNN` (verify against the directory on disk — parallel worktrees make next free `NNN` (verify against the directory on disk — parallel worktrees make
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md). issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
- **The decision to adopt SDD itself** → - **The decision to adopt SDD itself** →
[`docs/adr/041-sdd-adoption.md`](../../docs/adr/041-sdd-adoption.md) (this is the [`docs/adr/042-sdd-adoption.md`](../../docs/adr/042-sdd-adoption.md) (this is the
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence). "ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
- **Feature-local decisions** that are only meaningful within one in-flight feature → - **Feature-local decisions** that are only meaningful within one in-flight feature →
beside that feature's spec, e.g. beside that feature's spec, e.g.

View File

@@ -3,7 +3,7 @@
**Version:** v1.0.0 **Version:** v1.0.0
**Status:** Ratified **Status:** Ratified
**Date:** 2026-06-13 **Date:** 2026-06-13
**Adoption ADR:** [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md) **Adoption ADR:** [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is > The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
> bound by this document. Rules here are deliberately few and absolute — guidance and > bound by this document. Rules here are deliberately few and absolute — guidance and
@@ -73,7 +73,7 @@
When this constitution changes, the author MUST, in the same PR: When this constitution changes, the author MUST, in the same PR:
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change). 1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists. 2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
3. Update any `.specify/templates/*` section that quotes a changed rule. 3. Update any `.specify/templates/*` section that quotes a changed rule.
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists. 4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.

View File

@@ -43,3 +43,66 @@
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done | | REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. --> <!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
| REQ-009 | every derived event: derived=true, type=PERSONAL, non-null derivedType, non-UUID id | #776 | derive-person-life-events | `timeline/TimelineEventService` | structural invariants asserted inline in every event test | Done |
| REQ-010 | every derived event: non-null non-blank primaryPersonName; Heirat also non-null non-blank relatedPersonName | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_with_displayname_for_both_spouses` | Done |
| REQ-011 | exactly one call to findAllFamilyMembers() and one to findAllSpouseEdges() — no N+1 | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents`, `relationship/RelationshipService#findAllSpouseEdges` | test structure: only batch-fetch mocks used (no per-person stubs) | Done |
| REQ-012 | familyMember=false persons excluded from Geburt/Tod assembly | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` (via PersonService.findAllFamilyMembers) | `DerivedEventsAssemblyTest#should_exclude_non_family_member_persons_from_derived_events` | Done |
| REQ-013 | SPOUSE_OF edge with one non-family-member spouse still emits 1 MARRIAGE event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_when_one_spouse_is_not_family_member` | Done |
| REQ-014 | empty family-member list → empty result, no error | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | `DerivedEventsAssemblyTest#should_emit_zero_events_when_no_family_members` | Done |
| REQ-015 | assembleDerivedEvents() annotated @Transactional(readOnly=true) | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | code-review check (annotation visible in source) | Done |
| REQ-016 | no PII (names, dates) in log statements — only aggregate counts | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | log-audit: single log.debug with result.size() and persons.size() only | Done |
| REQ-001 | GET /api/timeline requires READ_ALL permission; 401 unauthenticated, 403 wrong permission | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated`, `#returns_403_when_authenticated_without_read_all`, `#returns_200_with_read_all_permission` | Done |
| REQ-002 | within-band sort: precision rank desc (DAY>MONTH>SEASON>YEAR>APPROX), then date asc, then title alpha, then id tiebreak | #777 | timeline-assembly | `timeline/TimelineService#WITHIN_BAND_ORDER` | `TimelineServiceTest#within_band_order_day_precision_sorts_before_year`, `#within_band_order_same_precision_and_date_sorts_alphabetically`, `#within_band_order_same_title_uses_document_id_as_tiebreak`, `#test5_day_precision_sorts_before_year_in_same_year_band`, `#test6_same_precision_same_date_sorted_alphabetically_by_title` | Done |
| REQ-003 | null eventDate OR UNKNOWN precision → undated bucket (never in a year band) | #777 | timeline-assembly | `timeline/TimelineService#bucketByYear` | `TimelineServiceTest#test3a_null_date_letter_goes_to_undated`, `#test3b_unknown_precision_letter_goes_to_undated` | Done |
| REQ-004 | RANGE events placed in start-year band only; null eventDateEnd does not crash; start year outside [fromYear,toYear] → excluded | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents` | `TimelineServiceTest#test7a_range_event_placed_only_in_start_year_band`, `#test7b_range_event_with_null_eventDateEnd_does_not_crash`, `#test8_range_event_excluded_when_start_year_before_fromYear`, `#test15_range_event_start_year_equal_to_fromYear_is_included` | Done |
| REQ-005 | null sender and null senderText on a document → senderName="" in the TimelineEntryDTO | #777 | timeline-assembly | `timeline/TimelineService#toLetterEntry` | `TimelineServiceTest#test4_letter_with_null_sender_and_null_senderText_produces_empty_names` | Done |
| REQ-006 | personId filter: include document when personId is sender OR receiver | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
| REQ-007 | documents domain letters always included (no type filter applied to LETTER kind) | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments`, `#assemble` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events`, `#test1_empty_archive_returns_empty_dto`, `#test2_one_year_letter_returns_one_year_band` | Done |
| REQ-008 | personId filter dedup: sender+receiver same person → document appears exactly once | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
| REQ-009 | type filter applies to events only; letters (LETTER kind) always pass | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents`, `#assembleDerivedEventsLayer` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events` | Done |
| REQ-010 | generation filter: PersonService.getPersonsByGeneration(N) used to build person-id set; filters all three layers | #777 | timeline-assembly | `timeline/TimelineService#assemble`, `person/PersonService#getPersonsByGeneration`, `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test9b_generation_filter_includes_letter_when_sender_matches_generation`, `TimelineServiceIntegrationTest#findByGeneration_returns_matching_persons`, `#findByGeneration_returns_empty_list_not_npe_when_no_match`, `#findByGeneration_does_not_return_null_generation_persons` | Done |
| REQ-011 | fromYear/toYear inclusive year-range filter; single-year window (fromYear==toYear); one-sided filter (fromYear only) | #777 | timeline-assembly | `timeline/TimelineService#passesYearFilter` | `TimelineServiceTest#test9c_fromYear_toYear_inclusive_single_year_window`, `#test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards` | Done |
| REQ-012 | combined filters AND logic — entry must pass all active filters | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test10_adversarial_and_logic_neither_event_passes_both_filters`, `#test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match` | Done |
| REQ-013 | empty archive (no events, no persons, no documents) → TimelineDTO { years=[], undated=[] } | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test1_empty_archive_returns_empty_dto` | Done |
| REQ-014 | unauthenticated request → 401 Unauthorized | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated` | Done |
| REQ-015 | authenticated without READ_ALL → 403 Forbidden | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_403_when_authenticated_without_read_all` | Done |
| REQ-016 | fromYear > toYear → 400 Bad Request | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#fromYear_greater_than_toYear_throws_bad_request`, `TimelineControllerTest#returns_400_when_fromYear_greater_than_toYear` | Done |
| REQ-017 | generation < 0 → 400 Bad Request (@Min(0) on controller param) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_when_generation_is_negative` | Done |
| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done |
| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done |
| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done |
| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>` | Done |
| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done |
| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done |
| REQ-006 | single `<ol>` chronological; each band a `<section>` with sticky `<h2>` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>`, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done |
| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done |
| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done |
| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 19141918 with a Zeitraum aria-label` | Done |
| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done |
| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done |
| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done |
| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done |
| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done |
| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done |
| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done |
| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done |
| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done |
| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done |
| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done |
| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done |
| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done |
| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done |
| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels localized per locale) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#timeline layer/derived labels are localized per locale`, Paraglide compile | Done |
| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done |
| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done |
| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done |

View File

@@ -99,7 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain │ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain ├── tag/ Tag domain
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository ├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
└── user/ User domain — AppUser, UserGroup, UserService └── user/ User domain — AppUser, UserGroup, UserService
``` ```
@@ -121,6 +121,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) | | `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` | | `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail | | `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List<UUID>`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -206,6 +207,7 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik) ├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new ├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum) ├── stammbaum/ Family tree (Stammbaum)
├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
├── themen/ Topics directory — browsable tag index ├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done ├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management ├── admin/ User, group, tag, OCR, system management

View File

@@ -3,7 +3,7 @@
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform, How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR → machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
multi-persona review → red/green TDD). It does not replace any of that — see multi-persona review → red/green TDD). It does not replace any of that — see
[ADR-041](./docs/adr/041-sdd-adoption.md) for the why. [ADR-042](./docs/adr/042-sdd-adoption.md) for the why.
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and - **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation). [`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
@@ -179,7 +179,7 @@ issue body for you via the Gitea API.)
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
in ADR-041's revision log (or a superseding ADR for MAJOR). in ADR-042's revision log (or a superseding ADR for MAJOR).
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never - **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
duplicate or contradict it. duplicate or contradict it.
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free - **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free

View File

@@ -56,6 +56,11 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück) // Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
boolean existsByOriginalFilename(String originalFilename); boolean existsByOriginalFilename(String originalFilename);
// Bulk-fetch for global timeline path — single query with sender+receivers eager-loaded.
@EntityGraph("Document.list")
@Query("SELECT d FROM Document d")
List<Document> findAllForTimeline();
// lazy @BatchSize(50) fallback active; see ADR-022 // lazy @BatchSize(50) fallback active; see ADR-022
@EntityGraph("Document.full") @EntityGraph("Document.full")
List<Document> findBySenderId(UUID senderId); List<Document> findBySenderId(UUID senderId);

View File

@@ -1051,6 +1051,10 @@ public class DocumentService {
return documentRepository.findDocumentsWithoutVersions(); return documentRepository.findDocumentsWithoutVersions();
} }
public List<Document> getAllForTimeline() {
return documentRepository.findAllForTimeline();
}
public List<Document> getDocumentsBySender(UUID senderId) { public List<Document> getDocumentsBySender(UUID senderId) {
return documentRepository.findBySenderId(senderId); return documentRepository.findBySenderId(senderId);
} }

View File

@@ -242,4 +242,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
) )
""", nativeQuery = true) """, nativeQuery = true)
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target); void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
// Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows).
List<Person> findByGeneration(Integer generation);
} }

View File

@@ -210,6 +210,10 @@ public class PersonService {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc(); return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
} }
public List<Person> getPersonsByGeneration(Integer generation) {
return personRepository.findByGeneration(generation);
}
@Transactional @Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) { public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId); Person person = getById(personId);

View File

@@ -86,6 +86,15 @@ public class RelationshipService {
return new NetworkDTO(nodes, edges); return new NetworkDTO(nodes, edges);
} }
/**
* Returns all {@code SPOUSE_OF} edges with both person sides JOIN FETCHed.
* Used by {@code TimelineService.assembleDerivedEvents()} to build Heirat events
* without per-edge N+1 queries.
*/
public List<PersonRelationship> findAllSpouseEdges() {
return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF));
}
@Transactional @Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) { public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) { if (personId.equals(dto.relatedPersonId())) {

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.timeline;
/** Discriminator for derived life-events assembled from Person / PersonRelationship data. */
public enum DerivedEventType {
BIRTH,
DEATH,
MARRIAGE
}

View File

@@ -0,0 +1,7 @@
package org.raddatz.familienarchiv.timeline;
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
public enum Kind {
EVENT,
LETTER
}

View File

@@ -0,0 +1,33 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/timeline")
@Validated
@RequiredArgsConstructor
public class TimelineController {
private final TimelineService timelineService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public TimelineDTO getTimeline(
@RequestParam(required = false) UUID personId,
@RequestParam(required = false) @Min(0) Integer generation,
@RequestParam(required = false) EventType type,
@RequestParam(required = false) Integer fromYear,
@RequestParam(required = false) Integer toYear) {
return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear));
}
}

View File

@@ -0,0 +1,15 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/**
* Assembled timeline response. Year bands are sorted ascending (oldest first).
* Undated entries have no usable date or {@code UNKNOWN} precision.
*/
public record TimelineDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineYearDTO> years,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> undated
) {
}

View File

@@ -0,0 +1,42 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived
* life-events ({@link DerivedEventType}), and archive letters (Documents).
*
* <p><b>Edit-affordance contract (for issue #7):</b> {@code derived == true || eventId == null}
* means no edit link should be rendered by the frontend.
*
* <p><b>Letter display fields:</b> {@code senderName} — {@code ""} means unknown/unlinked
* correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for
* {@link Kind#LETTER} entries.
*
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
* an event-type badge for letters.
*
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/
public record TimelineEntryDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName,
LocalDate eventDate,
LocalDate eventDateEnd,
String title,
EventType type,
UUID eventId,
UUID documentId,
List<UUID> linkedPersonIds,
DerivedEventType derivedType
) {
}

View File

@@ -10,6 +10,8 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef; import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView; import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
@@ -40,6 +42,7 @@ public class TimelineEventService {
private final TimelineEventRepository events; private final TimelineEventRepository events;
private final PersonService personService; private final PersonService personService;
private final DocumentService documentService; private final DocumentService documentService;
private final RelationshipService relationshipService;
@Transactional @Transactional
public TimelineEventView create(TimelineEventRequest request, UUID actorId) { public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
@@ -229,6 +232,83 @@ public class TimelineEventService {
return resolved; return resolved;
} }
// --- derived event assembly ---
/**
* Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
* PersonRelationship data. Computed on read, never persisted.
*
* <p>Derived events are computed, never persisted, and cannot be mutated via the events API
* (enforced in #5). Ids produced by this method are structurally non-UUID
* ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
* write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
* independently enforce {@code READ_ALL} authorization before invoking this method
* (see ADR-043).
*/
@Transactional(readOnly = true)
public List<TimelineEntryDTO> assembleDerivedEvents() {
List<Person> persons = personService.findAllFamilyMembers();
List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();
List<TimelineEntryDTO> result = new ArrayList<>();
result.addAll(buildBirthEvents(persons));
result.addAll(buildDeathEvents(persons));
result.addAll(buildMarriageEvents(spouseEdges));
log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
return result;
}
private List<TimelineEntryDTO> buildBirthEvents(List<Person> persons) {
return persons.stream()
.filter(p -> p.getBirthDate() != null)
.map(p -> new TimelineEntryDTO(
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
p.getBirthDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.BIRTH))
.toList();
}
private List<TimelineEntryDTO> buildDeathEvents(List<Person> persons) {
return persons.stream()
.filter(p -> p.getDeathDate() != null)
.map(p -> new TimelineEntryDTO(
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
p.getDeathDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.DEATH))
.toList();
}
private List<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
// DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
// in-memory dedup on relationship row id is a defensive assertion.
Set<UUID> seen = new HashSet<>();
List<TimelineEntryDTO> result = new ArrayList<>();
for (PersonRelationship r : spouseEdges) {
if (seen.add(r.getId())) {
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
LocalDate eventDate = r.getFromYear() != null
? LocalDate.of(r.getFromYear(), 1, 1)
: null;
DatePrecision precision = r.getFromYear() != null
? DatePrecision.YEAR
: DatePrecision.UNKNOWN;
String title = r.getPerson().getDisplayName()
+ " & " + r.getRelatedPerson().getDisplayName();
result.add(new TimelineEntryDTO(
Kind.EVENT, precision, true, "", "",
eventDate, null,
title, EventType.PERSONAL,
null, null,
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
DerivedEventType.MARRIAGE));
}
}
return result;
}
// --- view assembly (explicit allow-list; never the raw entity) --- // --- view assembly (explicit allow-list; never the raw entity) ---
private TimelineEventView toView(TimelineEvent event) { private TimelineEventView toView(TimelineEvent event) {

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.timeline;
import java.util.UUID;
/**
* Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}.
* All fields are nullable — null means "no constraint on this dimension".
*/
public record TimelineFilter(
UUID personId,
Integer generation,
EventType type,
Integer fromYear,
Integer toYear
) {
}

View File

@@ -0,0 +1,268 @@
package org.raddatz.familienarchiv.timeline;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
* derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
*
* <p>Cross-domain data is reached exclusively through domain services (PersonService,
* DocumentService). The only repository injected directly is {@link TimelineEventRepository}
* (same domain — constitution §1.3).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineService {
/** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
.thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
.thenComparing(e -> e.title() != null ? e.title() : "")
.thenComparing(e -> {
if (e.eventId() != null) return e.eventId().toString();
if (e.documentId() != null) return e.documentId().toString();
return "";
});
private final TimelineEventRepository eventRepository;
private final TimelineEventService timelineEventService;
private final DocumentService documentService;
private final PersonService personService;
/**
* Assembles the timeline for the given filter. All filters are ANDed.
* Throws {@link DomainException} (bad request) when fromYear &gt; toYear.
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
*
* <p>{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads,
* this method accesses lazy collections ({@link TimelineEvent#getPersons()},
* {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the
* repository sub-transaction closes. Without this annotation those accesses throw
* {@link org.hibernate.LazyInitializationException} in production (constitution §1.6).
*/
@Transactional(readOnly = true)
public TimelineDTO assemble(TimelineFilter filter) {
if (filter.fromYear() != null && filter.toYear() != null
&& filter.fromYear() > filter.toYear()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"toYear must not be before fromYear");
}
// Resolve generation person IDs once — used across all three layers
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
// ── curated events ───────────────────────────────────────────────────
List<TimelineEntryDTO> entries = new ArrayList<>();
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;
entries.add(mapEvent(ev));
}
// ── derived events ───────────────────────────────────────────────────
for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
if (!passesTypeFilter(derived.type(), filter.type())) continue;
if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
entries.add(derived);
}
// ── letters ─────────────────────────────────────────────────────────
List<Document> docs = fetchDocuments(filter.personId());
for (Document doc : docs) {
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
entries.add(mapDocument(doc));
}
return bucket(entries);
}
// ─── Bucketing ───────────────────────────────────────────────────────────
Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
for (TimelineEntryDTO e : entries) {
if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
}
return map;
}
private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
List<TimelineEntryDTO> undated = entries.stream()
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
.sorted(WITHIN_BAND_ORDER)
.toList();
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
List<TimelineYearDTO> years = byYear.entrySet().stream()
.map(e -> new TimelineYearDTO(e.getKey(),
e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
.toList();
return new TimelineDTO(years, undated);
}
// ─── Document fetch (global vs personId path) ────────────────────────────
private List<Document> fetchDocuments(UUID personId) {
if (personId == null) {
return documentService.getAllForTimeline();
}
// personId path: validate existence, then union sender+receiver (dedup by id)
personService.getById(personId);
Map<UUID, Document> seen = new LinkedHashMap<>();
for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
return new ArrayList<>(seen.values());
}
// ─── Filter predicates ───────────────────────────────────────────────────
private boolean passesTypeFilter(EventType entryType, EventType filterType) {
return filterType == null || filterType == entryType;
}
private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
int year = date.getYear();
if (filter.fromYear() != null && year < filter.fromYear()) return false;
if (filter.toYear() != null && year > filter.toYear()) return false;
return true;
}
private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
if (personId == null) return true;
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
}
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
if (personId == null) return true;
return linkedIds != null && linkedIds.contains(personId);
}
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
if (generation == null) return null;
return personService.getPersonsByGeneration(generation).stream()
.map(Person::getId)
.collect(Collectors.toSet());
}
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
if (persons == null || persons.isEmpty()) return false;
return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
}
private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
if (linkedIds == null || linkedIds.isEmpty()) return false;
return linkedIds.stream().anyMatch(genPersonIds::contains);
}
private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
if (genPersonIds == null) return true;
Person sender = doc.getSender();
if (sender != null && genPersonIds.contains(sender.getId())) return true;
Set<Person> receivers = doc.getReceivers();
if (receivers != null) {
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
}
return false;
}
// ─── Mapping ─────────────────────────────────────────────────────────────
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
List<UUID> personIds = ev.getPersons() == null ? List.of()
: ev.getPersons().stream().map(Person::getId).toList();
return new TimelineEntryDTO(
Kind.EVENT,
ev.getPrecision(),
false,
"",
"",
ev.getEventDate(),
ev.getEventDateEnd(),
ev.getTitle(),
ev.getType(),
ev.getId(),
null,
personIds,
null
);
}
private TimelineEntryDTO mapDocument(Document doc) {
return new TimelineEntryDTO(
Kind.LETTER,
doc.getMetaDatePrecision(),
false,
resolveSenderName(doc),
resolveReceiverName(doc),
doc.getDocumentDate(),
null,
doc.getTitle(),
null,
null,
doc.getId(),
List.of(),
null
);
}
private String resolveSenderName(Document doc) {
if (doc.getSender() != null) return doc.getSender().getDisplayName();
String text = doc.getSenderText();
return (text != null && !text.isBlank()) ? text : "";
}
private String resolveReceiverName(Document doc) {
Set<Person> receivers = doc.getReceivers();
if (receivers != null && !receivers.isEmpty()) {
return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
}
String text = doc.getReceiverText();
return (text != null && !text.isBlank()) ? text : "";
}
private static int precisionRank(DatePrecision precision) {
if (precision == null) return 0;
return switch (precision) {
case DAY -> 5;
case MONTH -> 4;
case SEASON -> 3;
case YEAR -> 2;
case APPROX -> 1;
default -> 0;
};
}
}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.timeline;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */
public record TimelineYearDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> entries
) {
}

View File

@@ -2943,4 +2943,17 @@ class DocumentServiceTest {
assertThat(result.buckets()).isEmpty(); assertThat(result.buckets()).isEmpty();
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class)); verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
} }
// --- getAllForTimeline ---
@Test
void getAllForTimeline_delegates_bulk_fetch_to_repository() {
Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build();
when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc));
List<Document> result = documentService.getAllForTimeline();
assertThat(result).containsExactly(doc);
verify(documentRepository).findAllForTimeline();
}
} }

View File

@@ -1105,4 +1105,25 @@ class PersonServiceTest {
assertThat(result.direct()).hasSize(1); assertThat(result.direct()).hasSize(1);
assertThat(result.partial()).isEmpty(); assertThat(result.partial()).isEmpty();
} }
// --- getPersonsByGeneration ---
@Test
void getPersonsByGeneration_delegates_to_repository() {
Person p = Person.builder().id(UUID.randomUUID()).lastName("Müller").generation(2).build();
when(personRepository.findByGeneration(2)).thenReturn(List.of(p));
List<Person> result = personService.getPersonsByGeneration(2);
assertThat(result).containsExactly(p);
}
@Test
void getPersonsByGeneration_returns_emptyList_when_no_match() {
when(personRepository.findByGeneration(99)).thenReturn(List.of());
List<Person> result = personService.getPersonsByGeneration(99);
assertThat(result).isEmpty();
}
} }

View File

@@ -100,6 +100,13 @@ class ArchitectureTest {
.and().resideInAPackage("..audit..") .and().resideInAPackage("..audit..")
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit")); .should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
@ArchTest
static final ArchRule services_only_access_own_domain_repositories_timeline =
noClasses()
.that().areAnnotatedWith(Service.class)
.and().resideInAPackage("..timeline..")
.should().dependOnClassesThat(foreignJpaRepositoryFor("timeline"));
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages. // Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages // Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
// where it can be audited and reasoned about independently. // where it can be audited and reasoned about independently.

View File

@@ -0,0 +1,377 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DerivedEventsAssemblyTest {
@Mock private TimelineEventRepository events;
@Mock private PersonService personService;
@Mock private DocumentService documentService;
@Mock private RelationshipService relationshipService;
@InjectMocks private TimelineEventService service;
// --- factory helpers ---
private Person makePerson(LocalDate birthDate, DatePrecision birthPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(true)
.birthDate(birthDate)
.birthDatePrecision(birthPrecision)
.build();
}
private Person makePersonWithDeath(LocalDate deathDate, DatePrecision deathPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Hans")
.lastName("Raddatz")
.familyMember(true)
.deathDate(deathDate)
.deathDatePrecision(deathPrecision)
.build();
}
private Person makePersonWithBoth(
LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(true)
.birthDate(birthDate)
.birthDatePrecision(birthPrecision)
.deathDate(deathDate)
.deathDatePrecision(deathPrecision)
.build();
}
private Person makeNonFamilyPerson(LocalDate birthDate, DatePrecision precision) {
return Person.builder()
.id(UUID.randomUUID())
.firstName("Anna")
.lastName("Müller")
.familyMember(false)
.birthDate(birthDate)
.birthDatePrecision(precision)
.build();
}
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(RelationType.SPOUSE_OF)
.fromYear(fromYear)
.build();
}
// --- REQ-001: birth events ---
@Test
void should_emit_one_geburt_for_person_with_birthdate() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO event = result.get(0);
assertThat(event.derived()).isTrue();
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.title()).isEqualTo(anna.getDisplayName());
}
// --- REQ-003: null birthDate → no Geburt event ---
@Test
void should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long todCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.DEATH)
.count();
assertThat(todCount).isZero();
}
// --- REQ-004: null deathDate → no Tod event ---
@Test
void should_emit_no_events_for_person_with_neither_date() {
Person nobody = Person.builder()
.id(UUID.randomUUID())
.firstName("Hans")
.lastName("Raddatz")
.familyMember(true)
.build();
when(personService.findAllFamilyMembers()).thenReturn(List.of(nobody));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
// --- REQ-002: death events ---
@Test
void should_emit_one_tod_for_person_with_deathdate() {
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO event = result.get(0);
assertThat(event.derived()).isTrue();
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
assertThat(event.title()).isEqualTo(hans.getDisplayName());
}
// --- REQ-002 + REQ-003 combined ---
@Test
void should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only() {
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.YEAR);
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
assertThat(result.get(0).derivedType()).isEqualTo(DerivedEventType.DEATH);
}
// --- REQ-005: Heirat with fromYear ---
@Test
void should_emit_one_heirat_for_spouse_edge_with_fromYear() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
List<TimelineEntryDTO> heiraten = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.derived()).isTrue();
assertThat(heirat.type()).isEqualTo(EventType.PERSONAL);
assertThat(heirat.derivedType()).isEqualTo(DerivedEventType.MARRIAGE);
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1930, 1, 1));
assertThat(heirat.precision()).isEqualTo(DatePrecision.YEAR);
}
// --- REQ-006: Heirat with null fromYear → emitted with UNKNOWN precision ---
@Test
void should_emit_unknown_precision_heirat_when_fromYear_is_null() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, null);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
List<TimelineEntryDTO> heiraten = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.eventDate()).isNull();
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
}
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
@Test
void should_emit_exactly_one_heirat_when_both_spouses_in_scope() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
@Test
void should_emit_two_heirat_for_person_married_to_two_partners() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePerson(null, DatePrecision.UNKNOWN);
Person karl = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge1 = makeSpouseEdge(anna, hans, 1930);
PersonRelationship edge2 = makeSpouseEdge(anna, karl, 1945);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans, karl));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge1, edge2));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(2);
}
// --- REQ-001 precision pass-through ---
@Test
void should_pass_birth_precision_through_unchanged() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
assertThat(result.get(0).precision()).isEqualTo(DatePrecision.DAY);
}
// --- REQ-008: synthetic prefixed ids, never UUID ---
@Test
void should_mint_prefixed_synthetic_ids_never_uuid() {
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).hasSize(1);
TimelineEntryDTO entry = result.get(0);
assertThat(entry.derived()).isTrue();
assertThat(entry.eventId()).isNull();
assertThat(entry.documentId()).isNull();
}
// --- REQ-010: display names on Heirat ---
@Test
void should_emit_heirat_with_displayname_for_both_spouses() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> heiraten = service.assembleDerivedEvents().stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.toList();
assertThat(heiraten).hasSize(1);
TimelineEntryDTO heirat = heiraten.get(0);
assertThat(heirat.title()).isNotNull().isNotBlank();
assertThat(heirat.linkedPersonIds()).hasSize(2);
}
// --- REQ-007 note: assumption/documentation test ---
@Test
void self_spouse_edge_invariant_is_enforced_by_db_constraint() {
// Assumption test — documents that the DB constraint prevents self-edges;
// the service does not guard this itself.
// The unique_spouse_pair index (V55) using LEAST/GREATEST is the authoritative guard.
// This test verifies that if an edge were somehow inserted (impossible in prod),
// the service would still produce one event (not zero or an exception).
Person anna = makePerson(null, DatePrecision.UNKNOWN);
PersonRelationship selfEdge = makeSpouseEdge(anna, anna, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(selfEdge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
// --- REQ-012: non-family-member persons excluded from Geburt/Tod ---
@Test
void should_exclude_non_family_member_persons_from_derived_events() {
Person nonMember = makeNonFamilyPerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of());
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
// --- REQ-013: Heirat emitted even when one spouse has familyMember=false ---
@Test
void should_emit_heirat_when_one_spouse_is_not_family_member() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person nonMember = makeNonFamilyPerson(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdge(anna, nonMember, 1930);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
long heiratCount = result.stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.count();
assertThat(heiratCount).isEqualTo(1);
}
// --- REQ-014: empty family-member list → empty result, no error ---
@Test
void should_emit_zero_events_when_no_family_members() {
when(personService.findAllFamilyMembers()).thenReturn(List.of());
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
assertThat(result).isEmpty();
}
}

View File

@@ -0,0 +1,139 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TimelineController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TimelineControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TimelineService timelineService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of());
@BeforeEach
void resolveDefaultPrincipal() {
when(userService.findByEmail("user"))
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
}
// ─── Security ─────────────────────────────────────────────────────────────
@Test
void returns_401_when_unauthenticated() throws Exception {
// REQ-014
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isUnauthorized());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL")
void returns_403_when_authenticated_without_read_all() throws Exception {
// REQ-015
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isForbidden());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_200_with_read_all_permission() throws Exception {
// REQ-001
when(timelineService.assemble(any())).thenReturn(EMPTY);
mockMvc.perform(get("/api/timeline"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.years").isArray())
.andExpect(jsonPath("$.undated").isArray());
}
// ─── Parameter binding ────────────────────────────────────────────────────
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void valid_params_are_forwarded_to_service() throws Exception {
UUID personId = UUID.randomUUID();
when(timelineService.assemble(any())).thenReturn(EMPTY);
mockMvc.perform(get("/api/timeline")
.param("personId", personId.toString())
.param("generation", "2")
.param("type", "HISTORICAL")
.param("fromYear", "1914")
.param("toYear", "1918"))
.andExpect(status().isOk());
verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918));
}
// ─── Validation errors ────────────────────────────────────────────────────
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_on_bad_type_value() throws Exception {
// REQ-018 — Spring enum binding rejects unknown value
mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_when_fromYear_greater_than_toYear() throws Exception {
// REQ-016 — service throws bad request, controller propagates it
when(timelineService.assemble(any()))
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"toYear must not be before fromYear"));
mockMvc.perform(get("/api/timeline")
.param("fromYear", "1920")
.param("toYear", "1914"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_400_when_generation_is_negative() throws Exception {
// REQ-017 — @Min(0) on generation parameter
mockMvc.perform(get("/api/timeline").param("generation", "-1"))
.andExpect(status().isBadRequest());
}
@Test
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
void returns_404_when_person_not_found() throws Exception {
// REQ-019
when(timelineService.assemble(any()))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND")));
}
}

View File

@@ -0,0 +1,105 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link TimelineService} and {@link PersonRepository#findByGeneration}
* against real Postgres. Verifies that assembled output reflects persisted curated events and
* that the generation query handles null-generation rows correctly.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineService timelineService;
@Autowired TimelineEventRepository timelineEventRepository;
@Autowired PersonRepository personRepository;
@PersistenceContext EntityManager em;
// ─── PersonRepository.findByGeneration ────────────────────────────────────
@Test
void findByGeneration_returns_matching_persons() {
personRepository.save(Person.builder().lastName("Gen2A").generation(2).build());
personRepository.save(Person.builder().lastName("Gen2B").generation(2).build());
personRepository.save(Person.builder().lastName("Gen3").generation(3).build());
em.flush();
List<Person> result = personRepository.findByGeneration(2);
assertThat(result).extracting(Person::getLastName)
.containsExactlyInAnyOrder("Gen2A", "Gen2B");
}
@Test
void findByGeneration_returns_empty_list_not_npe_when_no_match() {
personRepository.save(Person.builder().lastName("Gen1").generation(1).build());
em.flush();
List<Person> result = personRepository.findByGeneration(99);
assertThat(result).isNotNull().isEmpty();
}
@Test
void findByGeneration_does_not_return_null_generation_persons() {
personRepository.save(Person.builder().lastName("NullGen").build()); // generation stays null
em.flush();
List<Person> result = personRepository.findByGeneration(1);
assertThat(result).extracting(Person::getLastName).doesNotContain("NullGen");
}
// ─── TimelineService.assemble end-to-end ─────────────────────────────────
@Test
void assemble_includes_persisted_curated_event_in_correct_year_band() {
UUID actorId = UUID.randomUUID();
TimelineEvent event = timelineEventRepository.save(TimelineEvent.builder()
.title("Sarajevo")
.type(EventType.HISTORICAL)
.eventDate(LocalDate.of(1914, 6, 28))
.precision(DatePrecision.DAY)
.createdBy(actorId)
.updatedBy(actorId)
.build());
em.flush();
em.clear();
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, null, null));
assertThat(result.years()).anySatisfy(y -> {
assertThat(y.year()).isEqualTo(1914);
assertThat(y.entries()).anySatisfy(e -> {
assertThat(e.title()).isEqualTo("Sarajevo");
assertThat(e.kind()).isEqualTo(Kind.EVENT);
assertThat(e.eventId()).isEqualTo(event.getId());
});
});
}
}

View File

@@ -0,0 +1,72 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
/**
* Verifies that {@link TimelineService#assemble} does not throw
* {@link org.hibernate.LazyInitializationException} when events have linked persons.
*
* <p>No class-level {@code @Transactional} — each test method runs without an outer
* transaction, matching production behaviour (controller has no {@code @Transactional}).
* If {@code assemble()} lacks {@code @Transactional(readOnly=true)}, accessing
* {@code ev.getPersons()} on detached entities throws LazyInitializationException.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class TimelineServiceLazyLoadTest {
@MockitoBean
S3Client s3Client;
@Autowired
TransactionTemplate transactionTemplate;
@Autowired
TimelineService timelineService;
@Autowired
TimelineEventRepository timelineEventRepository;
@Autowired
PersonRepository personRepository;
@Test
void assemble_does_not_throw_when_event_has_linked_persons() {
UUID actorId = UUID.randomUUID();
// Commit outside any test-managed transaction so entities are detached on return
transactionTemplate.execute(status -> {
Person person = personRepository.save(Person.builder().lastName("Müller").build());
timelineEventRepository.save(TimelineEvent.builder()
.title("Linked event")
.type(EventType.HISTORICAL)
.eventDate(LocalDate.of(1914, 7, 28))
.precision(DatePrecision.DAY)
.createdBy(actorId)
.updatedBy(actorId)
.persons(new HashSet<>(Set.of(person)))
.build());
return null;
});
assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null)));
}
}

View File

@@ -0,0 +1,452 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TimelineServiceTest {
@Mock TimelineEventRepository eventRepository;
@Mock TimelineEventService timelineEventService;
@Mock DocumentService documentService;
@Mock PersonService personService;
@InjectMocks TimelineService timelineService;
// ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ────────────────────────
@Test
void within_band_order_day_precision_sorts_before_year() {
var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
var sorted = List.of(yearEntry, dayEntry).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted).containsExactly(dayEntry, yearEntry);
}
@Test
void within_band_order_same_precision_and_date_sorts_alphabetically() {
var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
var sorted = List.of(entryZ, entryA).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted).containsExactly(entryA, entryZ);
}
@Test
void within_band_order_same_title_uses_document_id_as_tiebreak() {
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
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);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
.toList();
assertThat(sorted.get(0).documentId()).isEqualTo(id1);
}
// ─── Assembly tests (issue-spec order) ──────────────────────────────────
@Test
void test1_empty_archive_returns_empty_dto() {
// REQ-013, REQ-007
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test2_one_year_letter_returns_one_year_band() {
// REQ-007
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER);
assertThat(result.undated()).isEmpty();
}
@Test
void test3a_null_date_letter_goes_to_undated() {
// REQ-003
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).hasSize(1);
}
@Test
void test3b_unknown_precision_letter_goes_to_undated() {
// REQ-003
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).isEmpty();
assertThat(result.undated()).hasSize(1);
}
@Test
void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() {
// REQ-005
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR)
.documentDate(LocalDate.of(1914, 1, 1))
.build(); // no sender, no senderText, no receivers, no receiverText
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(noFilters());
var entry = result.years().get(0).entries().get(0);
assertThat(entry.senderName()).isEqualTo("");
assertThat(entry.receiverName()).isEqualTo("");
}
@Test
void test5_day_precision_sorts_before_year_in_same_year_band() {
// REQ-002
var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter));
TimelineDTO result = timelineService.assemble(noFilters());
var entries = result.years().get(0).entries();
assertThat(entries).hasSize(2);
assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY);
assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void test6_same_precision_same_date_sorted_alphabetically_by_title() {
// REQ-002
var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA));
TimelineDTO result = timelineService.assemble(noFilters());
var entries = result.years().get(0).entries();
assertThat(entries).hasSize(2);
assertThat(entries.get(0).title()).isEqualTo("Adler");
assertThat(entries.get(1).title()).isEqualTo("Zimmer");
}
@Test
void test7a_range_event_placed_only_in_start_year_band() {
// REQ-004
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue();
}
@Test
void test7b_range_event_with_null_eventDateEnd_does_not_crash() {
// REQ-004
var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL,
LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null);
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException();
}
@Test
void test8_range_event_excluded_when_start_year_before_fromYear() {
// REQ-004
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
// fromYear=1915 → start year 1914 is outside → excluded
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null));
assertThat(result.years()).isEmpty();
}
@Test
void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() {
// REQ-009
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief");
var historicalEvent = event("Sarajevo", EventType.HISTORICAL,
LocalDate.of(1914, 6, 28), DatePrecision.DAY, null);
var personalEvent = event("Geburt", EventType.PERSONAL,
LocalDate.of(1914, 8, 1), DatePrecision.DAY, null);
when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
// filter: only HISTORICAL events
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null));
long letters = result.years().stream().flatMap(y -> y.entries().stream())
.filter(e -> e.kind() == Kind.LETTER).count();
long personalEvents = result.years().stream().flatMap(y -> y.entries().stream())
.filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count();
assertThat(letters).isEqualTo(1);
assertThat(personalEvents).isEqualTo(0);
}
@Test
void test9b_generation_filter_includes_letter_when_sender_matches_generation() {
// REQ-010
var sender = Person.builder().id(UUID.randomUUID())
.lastName("Mustermann").firstName("Max").generation(2).build();
var included = Document.builder().id(UUID.randomUUID()).title("Treffer")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(sender).build();
var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded));
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer");
}
@Test
void test9c_fromYear_toYear_inclusive_single_year_window() {
// REQ-011
var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher");
var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr");
var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr");
}
@Test
void test10_adversarial_and_logic_neither_event_passes_both_filters() {
// REQ-012 — type AND year must both pass
var wrongType = event("Personal", EventType.PERSONAL,
LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null);
var wrongYear = event("Historical outside", EventType.HISTORICAL,
LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null);
when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() {
// REQ-008
UUID personId = UUID.randomUUID();
var person = Person.builder().id(personId).lastName("Mustermann").build();
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(person)
.receivers(Set.of(person))
.build();
when(personService.getById(personId)).thenReturn(person);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc));
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null));
long total = result.years().stream().mapToLong(y -> y.entries().size()).sum()
+ result.undated().size();
assertThat(total).isEqualTo(1);
}
@Test
void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() {
// REQ-012
UUID personId = UUID.randomUUID();
var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build();
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(person).build();
var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build();
when(personService.getById(personId)).thenReturn(person);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of());
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test13_null_generation_sender_not_returned_by_generation_filter() {
// REQ-020 — both sender and receiver have null generation → excluded
var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
.sender(nullGenSender).build();
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null));
assertThat(result.years()).isEmpty();
assertThat(result.undated()).isEmpty();
}
@Test
void test14_year_band_contains_only_event_when_no_letters_in_that_year() {
var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null);
when(eventRepository.findAll()).thenReturn(List.of(ev));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(noFilters());
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).entries()).hasSize(1);
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT);
}
@Test
void test15_range_event_start_year_equal_to_fromYear_is_included() {
// REQ-004 — inclusive lower bound
var rangeEvent = event("WW1", EventType.HISTORICAL,
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of());
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null));
assertThat(result.years()).hasSize(1);
assertThat(result.years().get(0).year()).isEqualTo(1914);
}
@Test
void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() {
// REQ-011
var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt");
var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst");
var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer");
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer));
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null));
assertThat(result.years()).hasSize(2);
assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue();
}
@Test
void fromYear_greater_than_toYear_throws_bad_request() {
// REQ-016 (service-layer guard)
assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914)))
.isInstanceOf(DomainException.class);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private static TimelineFilter noFilters() {
return new TimelineFilter(null, null, null, null, null);
}
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);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {
return Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(precision).documentDate(date).build();
}
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
return Document.builder().id(UUID.randomUUID()).title(title)
.metaDatePrecision(precision).documentDate(date).build();
}
private static TimelineEvent event(String title, EventType type, LocalDate date,
DatePrecision precision, LocalDate endDate) {
return TimelineEvent.builder().id(UUID.randomUUID())
.title(title).type(type)
.eventDate(date).precision(precision).eventDateEnd(endDate)
.build();
}
}

View File

@@ -168,7 +168,18 @@ _Not to be confused with a document item's optional note_ — a document item's
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040). **EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain. **Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain.
**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view).
**derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043).
_Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database.
**DerivedEventType** (`DerivedEventType`) `[internal]` — enum with three values: `BIRTH`, `DEATH`, `MARRIAGE`. Carried on `TimelineEntryDTO.derivedType`; `null` on curated-event entries exposed through the same DTO.
**derivedType** (`TimelineEntryDTO.derivedType`) `[internal]` — the `DerivedEventType` field distinguishing a derived Geburt/Tod/Heirat event from a curated one. Always non-null on derived events; `null` on curated events.
**assembleDerivedEvents()** (`TimelineEventService.assembleDerivedEvents()`) `[internal]` — the public `@Transactional(readOnly=true)` method that computes all derived events in one call: one batch fetch of family-member `Person`s via `PersonService.findAllFamilyMembers()` and one batch fetch of `SPOUSE_OF` edges via `RelationshipService.findAllSpouseEdges()`. Result is never persisted. Synthetic ids produced by this method (`birth:{uuid}`, `death:{uuid}`, `marriage:{uuid}`) are structurally non-UUID and must be rejected by any write endpoint. See ADR-043.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table. **Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.

View File

@@ -1,11 +1,12 @@
# ADR-041 — Adopt Spec-Driven Development (SDD) # ADR-042 — Adopt Spec-Driven Development (SDD)
**Status:** Accepted **Status:** Accepted
**Date:** 2026-06-13 **Date:** 2026-06-13
**Issue:** SDD integration (docs/sdd-integration branch) **Issue:** SDD integration (docs/sdd-integration branch)
> This is the "ADR-000" the SDD scaffold refers to, numbered 041 to fit the existing archive > This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
> sequence rather than starting a parallel one. See [`.specify/adrs/README.md`](../../.specify/adrs/README.md). > sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
## Context ## Context

View File

@@ -0,0 +1,110 @@
# ADR-043 — Derived person life-events: on-read assembly strategy
**Status:** Proposed
**Date:** 2026-06-13
**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat)
---
## Context
The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside
manually curated `TimelineEvent` rows. This data already exists in the `Person` entity
(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in
`PersonRelationship` rows with `relationType = SPOUSE_OF`.
Three architectural decisions needed before implementation could start:
1. **Computation strategy:** should derived events be materialised to the `timeline_events`
table, or assembled on every read from the source tables?
2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide
with real `TimelineEvent` UUIDs and signal read-only semantics to consumers?
3. **Service contract:** where does the assembly method live, and what is its public API?
---
## Decision 1 — On-read assembly, never persisted
Derived events are computed on every call to `assembleDerivedEvents()` and are never written
to any table.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. |
| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. |
| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. |
**Consequences:**
- No schema changes. No Flyway migration.
- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open
across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH.
- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member
persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale.
---
## Decision 2 — Synthetic prefixed String ids
Derived events receive ids of the form `birth:{personId}`, `death:{personId}`,
`marriage:{relationshipId}`, where the suffix is the UUID of the source entity.
**Format rules:**
- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`.
- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is
structurally non-UUID by construction.
- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages;
the in-memory `Set<UUID>` used during assembly is a defensive assertion, not primary
enforcement.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. |
| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. |
| Numeric sequence | No natural source sequence; would require a counter, adding state. |
**Consequences:**
- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and
serves a different purpose (CRUD admin view); it is not changed.
- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that
do not parse as `UUID` — enforced and tested in issue #5, not here.
- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side
caching and deduplication.
---
## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract
The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the
`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean.
**Domain boundary rules enforced by this decision:**
- `TimelineService` reaches `Person` and `PersonRelationship` data **only through
`PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**.
It never injects `PersonRepository` or `PersonRelationshipRepository`.
- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`,
`buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public.
- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling
endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller
outside #5 must do the same — this obligation is documented in the Javadoc of the method.
**Alternatives rejected:**
| Alternative | Reason rejected |
|-------------|-----------------|
| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. |
| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. |
---
## Related decisions
- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this
issue reads)
- ADR-040 — Timeline domain data model (establishes the `timeline/` package and
`TimelineEvent` entity this issue extends)
- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns
`List<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)

View File

@@ -6,19 +6,28 @@ title Component Diagram: API Backend — Timeline (Zeitstrahl)
ContainerDb(db, "PostgreSQL", "PostgreSQL 16") ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") { System_Boundary(backend, "API Backend (Spring Boot)") {
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in #777.") Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents).")
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, #775)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.") Component(timelineSvc, "TimelineEventService", "Spring Service", "Owns curated-event CRUD: assembles TimelineEventView inside the transaction (lazy ManyToMany + open-in-view=false, ADR-036/ADR-040), populates createdBy/updatedBy from the session principal, and translates optimistic-lock conflicts to DomainException.conflict. Also exposes assembleDerivedEvents(): computes Geburt/Tod/Heirat TimelineEntryDTOs on read from Person/PersonRelationship data — never persisted (ADR-043).")
Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, #775)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).") Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline/events reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
Component(timelineAssemblySvc, "TimelineService", "Spring Service", "Assembles GET /api/timeline response: merges curated TimelineEvent rows, derived life-events (via TimelineEventService), and archive letters (via DocumentService) into a year-bucketed TimelineDTO. Applies personId, generation, type, fromYear/toYear filters. WITHIN_BAND_ORDER: precision rank desc → date asc → title alpha → id tiebreak.")
Component(timelineAssemblyCtrl, "TimelineController", "Spring MVC", "Exposes GET /api/timeline (READ_ALL). Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear. @Validated on class for constraint enforcement.")
} }
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters") System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type), Document references for linked letters, and getAllForTimeline() bulk fetch")
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves") System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering")
Rel(timelineRepo, db, "SQL queries", "JDBC") Rel(timelineRepo, db, "SQL queries", "JDBC")
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)") Rel(timelineSvc, timelineRepo, "Reads / writes events")
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)") Rel(timelineCtrl, timelineSvc, "Delegates to")
Rel(timelineRepo, personDomain, "References persons via join table") Rel(timelineRepo, personDomain, "References persons via join table")
Rel(timelineRepo, documentDomain, "References documents via join table") Rel(timelineRepo, documentDomain, "References documents via join table")
Rel(timelineSvc, personDomain, "findAllFamilyMembers() + findAllSpouseEdges() for derived-event assembly")
Rel(timelineAssemblyCtrl, timelineAssemblySvc, "Delegates to")
Rel(timelineAssemblySvc, timelineRepo, "findAll() for curated events")
Rel(timelineAssemblySvc, timelineSvc, "assembleDerivedEvents() for derived life-events")
Rel(timelineAssemblySvc, personDomain, "getPersonsByGeneration(), getById() for generation/personId filters")
Rel(timelineAssemblySvc, documentDomain, "getAllForTimeline(), getDocumentsBySender(), getDocumentsByReceiver() for letter layer")
@enduml @enduml

View File

@@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.") Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.") Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
@@ -27,6 +28,8 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON") Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser")
Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")

View File

@@ -34,6 +34,7 @@ src/
│ ├── api/ # Internal API proxies (server-side only) │ ├── api/ # Internal API proxies (server-side only)
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new) │ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
│ ├── stammbaum/ # Family tree │ ├── stammbaum/ # Family tree
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline
│ ├── enrich/ # Enrichment workflow ([id], done) │ ├── enrich/ # Enrichment workflow ([id], done)
│ ├── hilfe/transkription/ # Transcription help page │ ├── hilfe/transkription/ # Transcription help page
│ ├── profile/ # User profile settings │ ├── profile/ # User profile settings
@@ -49,6 +50,7 @@ src/
│ │ ├── relationship/ # Relationship form + chip components │ │ ├── relationship/ # Relationship form + chip components
│ │ └── genealogy/ # Stammbaum (family tree) components │ │ └── genealogy/ # Stammbaum (family tree) components
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker │ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
│ ├── geschichte/ # Geschichte (story) domain: editor + card │ ├── geschichte/ # Geschichte (story) domain: editor + card
│ ├── notification/ # Notification bell + dropdown + store │ ├── notification/ # Notification bell + dropdown + store
│ ├── activity/ # Activity feed (Chronik) components │ ├── activity/ # Activity feed (Chronik) components
@@ -59,8 +61,8 @@ src/
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.) │ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
│ │ ├── server/ # Server-only utilities (locale, session) │ │ ├── server/ # Server-only utilities (locale, session)
│ │ ├── services/ # Client-side service helpers │ │ ├── services/ # Client-side service helpers
│ │ ├── utils/ # Pure utility functions (date, search, etc.) │ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip)
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.) │ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.)
│ │ ├── dashboard/ # Dashboard stat components │ │ ├── dashboard/ # Dashboard stat components
│ │ ├── discussion/ # CommentThread + shared discussion UI │ │ ├── discussion/ # CommentThread + shared discussion UI
│ │ ├── help/ # Help/FAQ page components │ │ ├── help/ # Help/FAQ page components

View File

@@ -0,0 +1,84 @@
import { test, expect, type APIRequestContext } from '@playwright/test';
/**
* Global /zeitstrahl timeline (#779). Runs against the real stack with the
* seeded admin session (auth.setup). Covers the primary journey (nav → page,
* timeline inside <main>) and the 320px no-overflow guarantee on a populated
* timeline seeded with 25+char correspondent names (REQ-005).
*/
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 with long sender/receiver names so it lands on the timeline. */
async function seedDatedLetter(request: APIRequestContext) {
const senderId = await createPerson(
request,
'Friedrich-Wilhelm',
`Maximilian von Habsburg ${stamp()}`
);
const receiverId = await createPerson(
request,
'Maria-Magdalena',
`Hohenzollern-Sigmaringen ${stamp()}`
);
const createRes = await request.post('/api/documents', {
multipart: { title: `E2E Zeitstrahl Brief ${stamp()}` }
});
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: `E2E Zeitstrahl Brief ${stamp()}`,
documentDate: '1915-06-15',
metaDatePrecision: 'DAY',
senderId,
receiverIds: receiverId
}
});
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
}
test.describe('Zeitstrahl — global timeline (#779)', () => {
test('nav link opens /zeitstrahl and the timeline lives in <main>', async ({ page }) => {
await page.goto('/');
await page.getByRole('navigation').getByRole('link', { name: 'Zeitstrahl' }).first().click();
await expect(page).toHaveURL(/\/zeitstrahl$/);
await expect(page.getByRole('heading', { level: 1, name: 'Zeitstrahl' })).toBeVisible();
// The main landmark contains either the populated <ol> or the empty state.
const main = page.getByRole('main');
const ol = main.locator('ol');
const empty = main.getByText('Noch keine Ereignisse.');
await expect(async () => {
const populated = (await ol.count()) > 0;
const isEmpty = await empty.isVisible().catch(() => false);
expect(populated || isEmpty).toBe(true);
}).toPass();
});
test('no horizontal overflow at 320px with long correspondent names (REQ-005)', async ({
page,
request
}) => {
await seedDatedLetter(request);
await page.setViewportSize({ width: 320, height: 900 });
await page.goto('/zeitstrahl');
// Populated: the seeded letter puts the timeline <ol> in the DOM.
await expect(page.getByRole('main').locator('ol')).toHaveCount(1);
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
expect(scrollWidth).toBe(320);
});
});

View File

@@ -215,6 +215,7 @@ export default defineConfig(
'ocr', 'ocr',
'activity', 'activity',
'conversation', 'conversation',
'timeline',
'shared' 'shared'
] ]
} }

View File

@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "{count} werden bearbeitet", "bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum", "nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten", "nav_geschichten": "Geschichten",
"nav_zeitstrahl": "Zeitstrahl",
"timeline_heading": "Zeitstrahl",
"timeline_empty_state": "Noch keine Ereignisse.",
"timeline_undated_section": "Ohne Datum",
"timeline_unknown_person": "Unbekannt",
"timeline_gap_empty": "keine Einträge",
"timeline_letters_count": "{count} Briefe",
"timeline_strip_expand": "Briefe anzeigen",
"timeline_range_aria": "Zeitraum: {from} bis {to}",
"timeline_layer_world": "Weltgeschehen",
"timeline_layer_family": "Familie",
"timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.", "error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.", "error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",

View File

@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "{count} will be edited", "bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree", "nav_stammbaum": "Family tree",
"nav_geschichten": "Stories", "nav_geschichten": "Stories",
"nav_zeitstrahl": "Timeline",
"timeline_heading": "Timeline",
"timeline_empty_state": "No events yet.",
"timeline_undated_section": "Without Date",
"timeline_unknown_person": "Unknown",
"timeline_gap_empty": "no entries",
"timeline_letters_count": "{count} letters",
"timeline_strip_expand": "Show letters",
"timeline_range_aria": "Period: {from} to {to}",
"timeline_layer_world": "World events",
"timeline_layer_family": "Family",
"timeline_derived_birth": "Birth",
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"error_geschichte_not_found": "The story was not found.", "error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.", "error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.", "error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",

View File

@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "Se editarán {count}", "bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico", "nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias", "nav_geschichten": "Historias",
"nav_zeitstrahl": "Línea de tiempo",
"timeline_heading": "Línea de tiempo",
"timeline_empty_state": "Aún no hay eventos.",
"timeline_undated_section": "Sin Fecha",
"timeline_unknown_person": "Desconocido",
"timeline_gap_empty": "sin entradas",
"timeline_letters_count": "{count} cartas",
"timeline_strip_expand": "Mostrar cartas",
"timeline_range_aria": "Período: {from} a {to}",
"timeline_layer_world": "Acontecimientos mundiales",
"timeline_layer_family": "Familia",
"timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"error_geschichte_not_found": "No se encontró la historia.", "error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.", "error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.", "error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",

View File

@@ -30,6 +30,7 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`.
- `tag/TagInput.svelte` — tag chip input - `tag/TagInput.svelte` — tag chip input
- `ocr/OcrProgress.svelte` — job status indicator in the document header - `ocr/OcrProgress.svelte` — job status indicator in the document header
- `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI - `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI
- `shared/utils/monthBuckets.ts` — the density chart's pure month-bucket math (boundaries, gap-fill, year aggregation, axis ticks) now lives in `shared/` so the `timeline/` domain can reuse it; `document/timeline.ts` keeps only the `/api/documents/density` glue (`fetchDensity`, `buildDensityUrl`)
## Backend counterpart ## Backend counterpart

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { formatTickLabel } from '$lib/document/timeline'; import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';

View File

@@ -7,7 +7,7 @@ import {
selectionBoundaryFrom, selectionBoundaryFrom,
selectionBoundaryTo, selectionBoundaryTo,
formatTickLabel formatTickLabel
} from '$lib/document/timeline'; } from '$lib/shared/utils/monthBuckets';
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte'; import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import TimelineBars from '$lib/document/TimelineBars.svelte'; import TimelineBars from '$lib/document/TimelineBars.svelte';

View File

@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { tick } from 'svelte'; import { tick } from 'svelte';
import TimelineDensityFilter from './TimelineDensityFilter.svelte'; import TimelineDensityFilter from './TimelineDensityFilter.svelte';
import { formatTickLabel } from './timeline'; import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline'; import { formatTickLabel, tickIndicesFor } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';

View File

@@ -1,191 +1,5 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { import { fetchDensity, buildDensityUrl } from './timeline';
monthBoundaryFrom,
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps,
fetchDensity,
buildDensityUrl,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo,
clipBucketsToRange,
tickIndicesFor,
formatTickLabel
} from './timeline';
describe('monthBoundaryFrom', () => {
it('returns the first day of the given month', () => {
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
});
it('handles January', () => {
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
});
});
describe('monthBoundaryTo', () => {
it('returns the last day of a 31-day month', () => {
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('returns the last day of a 30-day month', () => {
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
});
it('returns 28 for February in a non-leap year', () => {
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
});
it('returns 29 for February in a leap year', () => {
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
});
});
describe('buildMonthSequence', () => {
it('returns a single month when min and max are in the same month', () => {
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
});
it('returns months from minDate through maxDate inclusive', () => {
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
'1915-08',
'1915-09',
'1915-10',
'1915-11'
]);
});
it('crosses year boundaries correctly', () => {
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
'1915-11',
'1915-12',
'1916-01',
'1916-02'
]);
});
it('returns empty array when minDate or maxDate is null', () => {
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
expect(buildMonthSequence(null, null)).toEqual([]);
});
});
describe('fillDensityGaps', () => {
it('returns empty array when minDate or maxDate is null', () => {
expect(fillDensityGaps([], null, null)).toEqual([]);
});
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-11', count: 2 }
];
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
expect(result).toEqual([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 },
{ month: '1915-11', count: 2 }
]);
});
it('returns all-zero sequence when buckets array is empty', () => {
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
expect(result).toEqual([
{ month: '1915-08', count: 0 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 }
]);
});
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
const buckets = [
{ month: '1915-10', count: 3 },
{ month: '1915-08', count: 1 }
];
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
});
});
describe('aggregateToYears', () => {
it('returns empty array for empty input', () => {
expect(aggregateToYears([])).toEqual([]);
});
it('sums counts within the same year', () => {
const result = aggregateToYears([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
expect(result).toEqual([{ month: '1915', count: 15 }]);
});
it('produces one bucket per distinct year, sorted chronologically', () => {
const result = aggregateToYears([
{ month: '1916-01', count: 3 },
{ month: '1915-08', count: 5 },
{ month: '1916-04', count: 7 },
{ month: '1914-12', count: 1 }
]);
expect(result).toEqual([
{ month: '1914', count: 1 },
{ month: '1915', count: 5 },
{ month: '1916', count: 10 }
]);
});
});
describe('clipBucketsToRange', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 },
{ month: '1915-11', count: 3 }
];
it('returns the original buckets when range bounds are null', () => {
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
});
it('keeps only buckets whose month falls within the range', () => {
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
it('returns an empty array when the range excludes everything', () => {
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
});
it('treats partial dates correctly when bounds cross month boundaries', () => {
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('handles year labels (YYYY)', () => {
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
});
});
describe('buildDensityUrl', () => { describe('buildDensityUrl', () => {
it('returns the bare endpoint when no filters provided', () => { it('returns the bare endpoint when no filters provided', () => {
@@ -309,84 +123,3 @@ describe('fetchDensity', () => {
warn.mockRestore(); warn.mockRestore();
}); });
}); });
describe('tickIndicesFor', () => {
it('returns no indices for an empty bucket list', () => {
expect(tickIndicesFor([])).toEqual([]);
});
it('picks years divisible by 25 when the year span exceeds 120', () => {
const buckets = Array.from({ length: 150 }, (_, i) => ({
month: String(1875 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
});
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
const buckets = Array.from({ length: 50 }, (_, i) => ({
month: String(1900 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
});
it('picks January boundaries for long month ranges', () => {
const buckets = [
{ month: '1914-08', count: 1 },
{ month: '1914-09', count: 1 },
{ month: '1914-10', count: 1 },
{ month: '1914-11', count: 1 },
{ month: '1914-12', count: 1 },
{ month: '1915-01', count: 1 },
{ month: '1915-02', count: 1 },
{ month: '1915-03', count: 1 },
{ month: '1915-04', count: 1 },
{ month: '1915-05', count: 1 },
{ month: '1915-06', count: 1 },
{ month: '1915-07', count: 1 },
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 1 },
{ month: '1915-10', count: 1 },
{ month: '1915-11', count: 1 },
{ month: '1915-12', count: 1 },
{ month: '1916-01', count: 1 },
{ month: '1916-02', count: 1 }
];
const ticks = tickIndicesFor(buckets);
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
});
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
const buckets = Array.from({ length: 12 }, (_, i) => ({
month: `1905-${String(i + 1).padStart(2, '0')}`,
count: 1
}));
const ticks = tickIndicesFor(buckets);
expect(ticks.length).toBeGreaterThanOrEqual(5);
expect(ticks.length).toBeLessThanOrEqual(7);
expect(ticks[0]).toBe(0);
});
});
describe('formatTickLabel', () => {
it('returns the year string unchanged for year labels', () => {
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
});
it('formats month labels with the year by default', () => {
const result = formatTickLabel('1905-06', 'en-US');
expect(result).toMatch(/Jun/);
expect(result).toMatch(/1905/);
});
it('omits the year when omitYear is true', () => {
const result = formatTickLabel('1905-06', 'en-US', true);
expect(result).toMatch(/Jun/);
expect(result).not.toMatch(/1905/);
});
});

View File

@@ -12,160 +12,6 @@ export type DensityState = {
const SKIP: DensityState = { density: null, minDate: null, maxDate: null }; const SKIP: DensityState = { density: null, minDate: null, maxDate: null };
const EMPTY: DensityState = { density: [], minDate: null, maxDate: null }; const EMPTY: DensityState = { density: [], minDate: null, maxDate: null };
export function monthBoundaryFrom(yearMonth: string): string {
return `${yearMonth}-01`;
}
export function monthBoundaryTo(yearMonth: string): string {
const [year, month] = yearMonth.split('-').map(Number);
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
}
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
if (!minDate || !maxDate) return [];
const [minY, minM] = minDate.split('-').map(Number);
const [maxY, maxM] = maxDate.split('-').map(Number);
const sequence: string[] = [];
let year = minY;
let month = minM;
while (year < maxY || (year === maxY && month <= maxM)) {
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return sequence;
}
export function fillDensityGaps(
buckets: MonthBucket[],
minDate: string | null,
maxDate: string | null
): MonthBucket[] {
const sequence = buildMonthSequence(minDate, maxDate);
if (sequence.length === 0) return [];
const counts = new Map(buckets.map((b) => [b.month, b.count]));
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* Returns only the month buckets whose YYYY-MM falls inside the provided
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
* input array is returned unchanged. Used by the timeline's zoom-in tool to
* narrow the visible bars without refetching data.
*
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
*/
export function clipBucketsToRange(
buckets: MonthBucket[],
fromInclusive: string | null,
toInclusive: string | null
): MonthBucket[] {
if (!fromInclusive || !toInclusive) return buckets;
const fromMonth = fromInclusive.slice(0, 7);
const toMonth = toInclusive.slice(0, 7);
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
}
/**
* Aggregates month-granular buckets into one entry per year. Month strings are
* truncated to "YYYY" and counts are summed. Used when the date span is too
* long for month-granular bars to render at a clickable size.
*/
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
const totals = new Map<string, number>();
for (const b of buckets) {
const year = b.month.slice(0, 4);
totals.set(year, (totals.get(year) ?? 0) + b.count);
}
return Array.from(totals.entries())
.map(([year, count]) => ({ month: year, count }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
* (year) and return the matching LocalDate string.
*/
export function selectionBoundaryFrom(label: string): string {
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
}
export function selectionBoundaryTo(label: string): string {
if (label.length === 4) return `${label}-12-31`;
return monthBoundaryTo(label);
}
/**
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
* to whether bars are years or months and how many are visible:
* - Year bars: pick years divisible by a step that scales with range length
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
* one year zoomed in to months), fall back to evenly spaced ticks so we
* show ~6 labels even when no January boundary exists.
*/
export function tickIndicesFor(filled: MonthBucket[]): number[] {
if (filled.length === 0) return [];
const isYearMode = filled[0].month.length === 4;
const indices: number[] = [];
if (isYearMode) {
const years = filled.length;
const step =
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
for (let i = 0; i < filled.length; i++) {
const year = parseInt(filled[i].month, 10);
if (year % step === 0) indices.push(i);
}
return indices;
}
if (filled.length <= 18) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
return indices;
}
// Long month range — pick January boundaries (year breaks).
for (let i = 0; i < filled.length; i++) {
if (filled[i].month.endsWith('-01')) indices.push(i);
}
// Fallback if there's no January in the visible range (rare): even spacing.
if (indices.length === 0) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
}
return indices;
}
/**
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
* "Jan", "Feb", … without repetition.
*/
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
if (label.length === 4) return label;
const [yearStr, monthStr] = label.split('-');
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
const opts: Intl.DateTimeFormatOptions = omitYear
? { month: 'short' }
: { month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(locale, opts).format(date);
}
/** /**
* The subset of /documents URL params that should narrow the density chart. * The subset of /documents URL params that should narrow the density chart.
* Date bounds (`from`/`to`) are intentionally excluded — see * Date bounds (`from`/`to`) are intentionally excluded — see

View File

@@ -1032,6 +1032,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getTimeline"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags": { "/api/tags": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2413,6 +2429,38 @@ export interface components {
contributors: components["schemas"]["ActivityActorDTO"][]; contributors: components["schemas"]["ActivityActorDTO"][];
hasMoreContributors: boolean; hasMoreContributors: boolean;
}; };
TimelineDTO: {
years: components["schemas"]["TimelineYearDTO"][];
undated: components["schemas"]["TimelineEntryDTO"][];
};
TimelineEntryDTO: {
/** @enum {string} */
kind: "EVENT" | "LETTER";
/** @enum {string} */
precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
derived: boolean;
senderName: string;
receiverName: string;
/** Format: date */
eventDate?: string;
/** Format: date */
eventDateEnd?: string;
title?: string;
/** @enum {string} */
type?: "PERSONAL" | "HISTORICAL";
/** Format: uuid */
eventId?: string;
/** Format: uuid */
documentId?: string;
linkedPersonIds?: string[];
/** @enum {string} */
derivedType?: "BIRTH" | "DEATH" | "MARRIAGE";
};
TimelineYearDTO: {
/** Format: int32 */
year: number;
entries: components["schemas"]["TimelineEntryDTO"][];
};
TagTreeNodeDTO: { TagTreeNodeDTO: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -2468,10 +2516,10 @@ export interface components {
birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */ /** Format: date */
deathDate?: string; deathDate?: string;
/** @enum {string} */
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
personType?: string; personType?: string;
familyMember?: boolean; familyMember?: boolean;
/** @enum {string} */
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
provisional?: boolean; provisional?: boolean;
/** Format: int32 */ /** Format: int32 */
birthYear?: number; birthYear?: number;
@@ -4993,6 +5041,32 @@ export interface operations {
}; };
}; };
}; };
getTimeline: {
parameters: {
query?: {
personId?: string;
generation?: number;
type?: "PERSONAL" | "HISTORICAL";
fromYear?: number;
toYear?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineDTO"];
};
};
};
};
searchTags: { searchTags: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -40,4 +40,32 @@ describe('message key parity', () => {
expect(es).toHaveProperty('layout_menu_open'); expect(es).toHaveProperty('layout_menu_open');
expect(es).toHaveProperty('layout_menu_close'); expect(es).toHaveProperty('layout_menu_close');
}); });
// REQ-024: the timeline layer/life-event labels feed sr-only / aria text, so
// they are localized per locale (the original German-only MVP decision was
// reversed for accessibility). Pin the values so en/es can never silently
// drift back to the German source strings.
it('timeline layer/derived labels are localized per locale (REQ-024)', () => {
expect(de).toMatchObject({
timeline_layer_world: 'Weltgeschehen',
timeline_layer_family: 'Familie',
timeline_derived_birth: 'Geburt',
timeline_derived_death: 'Tod',
timeline_derived_marriage: 'Heirat'
});
expect(en).toMatchObject({
timeline_layer_world: 'World events',
timeline_layer_family: 'Family',
timeline_derived_birth: 'Birth',
timeline_derived_death: 'Death',
timeline_derived_marriage: 'Marriage'
});
expect(es).toMatchObject({
timeline_layer_world: 'Acontecimientos mundiales',
timeline_layer_family: 'Familia',
timeline_derived_birth: 'Nacimiento',
timeline_derived_death: 'Fallecimiento',
timeline_derived_marriage: 'Matrimonio'
});
});
}); });

View File

@@ -15,11 +15,13 @@ If any condition fails, the file belongs in the domain folder of its primary con
## What this folder owns ## What this folder owns
| Sub-folder / file | Purpose | | Sub-folder / file | Purpose |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions | | `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions |
| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping | | `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping |
| `types.ts` | Cross-domain TypeScript interfaces | | `types.ts` | Cross-domain TypeScript interfaces |
| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) | | `utils.ts` | Pure utility functions (date formatting, sorting, debounce) |
| `utils/monthBuckets.ts` | Pure month-bucket math (boundaries, sequences, gap-fill, year aggregation, axis ticks) shared by the `document/` density chart and the `timeline/` density strip — moved up from `document/timeline.ts` so `timeline/` need not import `document/` |
| `primitives/Sparkline.svelte` | Fixed-series bar sparkline (one bar per value) — used by the timeline density strip |
| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) | | `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) |
| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells | | `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells |
| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` | | `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` |

View File

@@ -0,0 +1,38 @@
<script lang="ts">
/**
* A minimal fixed-series bar sparkline: one bar per value, heights scaled to the
* largest value. Presentational only — callers supply the already-bucketed
* counts. Used by the timeline density strip; reusable by the document chart.
*/
let {
values,
label,
class: className = ''
}: { values: number[]; label?: string; class?: string } = $props();
const max = $derived(Math.max(1, ...values));
// Empty buckets keep a faint floor so the series reads as a continuous axis
// rather than disappearing to nothing.
const MIN_HEIGHT_PCT = 4;
function heightPct(value: number): number {
if (value <= 0) return MIN_HEIGHT_PCT;
return Math.max(MIN_HEIGHT_PCT, (value / max) * 100);
}
</script>
<div
class="flex h-8 items-end gap-[1.5px] {className}"
role="img"
aria-label={label}
aria-hidden={label ? undefined : 'true'}
>
{#each values as value, i (i)}
<div
data-testid="sparkline-bar"
class="flex-1 rounded-[1px] bg-brand-mint"
style="height: {heightPct(value)}%"
></div>
{/each}
</div>

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import Sparkline from './Sparkline.svelte';
afterEach(() => cleanup());
describe('Sparkline', () => {
it('renders one bar per value', () => {
render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] });
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
expect(bars).toHaveLength(12);
});
it('scales bar heights relative to the largest value', () => {
render(Sparkline, { values: [5, 10, 0] });
const bars = document.querySelectorAll<HTMLElement>('[data-testid="sparkline-bar"]');
const h = (i: number) => parseFloat(bars[i].style.height);
// 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest.
expect(h(1)).toBeGreaterThan(h(0));
expect(h(0)).toBeGreaterThan(h(2));
});
it('exposes an accessible label when provided', () => {
render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' });
const img = document.querySelector('[role="img"]');
expect(img?.getAttribute('aria-label')).toBe('Monatsdichte');
});
});

View File

@@ -0,0 +1,267 @@
import { describe, it, expect } from 'vitest';
import {
monthBoundaryFrom,
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo,
clipBucketsToRange,
tickIndicesFor,
formatTickLabel
} from './monthBuckets';
describe('monthBoundaryFrom', () => {
it('returns the first day of the given month', () => {
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
});
it('handles January', () => {
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
});
});
describe('monthBoundaryTo', () => {
it('returns the last day of a 31-day month', () => {
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('returns the last day of a 30-day month', () => {
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
});
it('returns 28 for February in a non-leap year', () => {
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
});
it('returns 29 for February in a leap year', () => {
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
});
});
describe('buildMonthSequence', () => {
it('returns a single month when min and max are in the same month', () => {
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
});
it('returns months from minDate through maxDate inclusive', () => {
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
'1915-08',
'1915-09',
'1915-10',
'1915-11'
]);
});
it('crosses year boundaries correctly', () => {
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
'1915-11',
'1915-12',
'1916-01',
'1916-02'
]);
});
it('returns empty array when minDate or maxDate is null', () => {
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
expect(buildMonthSequence(null, null)).toEqual([]);
});
});
describe('fillDensityGaps', () => {
it('returns empty array when minDate or maxDate is null', () => {
expect(fillDensityGaps([], null, null)).toEqual([]);
});
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-11', count: 2 }
];
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
expect(result).toEqual([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 },
{ month: '1915-11', count: 2 }
]);
});
it('returns all-zero sequence when buckets array is empty', () => {
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
expect(result).toEqual([
{ month: '1915-08', count: 0 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 }
]);
});
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
const buckets = [
{ month: '1915-10', count: 3 },
{ month: '1915-08', count: 1 }
];
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
});
});
describe('aggregateToYears', () => {
it('returns empty array for empty input', () => {
expect(aggregateToYears([])).toEqual([]);
});
it('sums counts within the same year', () => {
const result = aggregateToYears([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
expect(result).toEqual([{ month: '1915', count: 15 }]);
});
it('produces one bucket per distinct year, sorted chronologically', () => {
const result = aggregateToYears([
{ month: '1916-01', count: 3 },
{ month: '1915-08', count: 5 },
{ month: '1916-04', count: 7 },
{ month: '1914-12', count: 1 }
]);
expect(result).toEqual([
{ month: '1914', count: 1 },
{ month: '1915', count: 5 },
{ month: '1916', count: 10 }
]);
});
});
describe('clipBucketsToRange', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 },
{ month: '1915-11', count: 3 }
];
it('returns the original buckets when range bounds are null', () => {
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
});
it('keeps only buckets whose month falls within the range', () => {
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
it('returns an empty array when the range excludes everything', () => {
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
});
it('treats partial dates correctly when bounds cross month boundaries', () => {
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('handles year labels (YYYY)', () => {
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
});
});
describe('tickIndicesFor', () => {
it('returns no indices for an empty bucket list', () => {
expect(tickIndicesFor([])).toEqual([]);
});
it('picks years divisible by 25 when the year span exceeds 120', () => {
const buckets = Array.from({ length: 150 }, (_, i) => ({
month: String(1875 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
});
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
const buckets = Array.from({ length: 50 }, (_, i) => ({
month: String(1900 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
});
it('picks January boundaries for long month ranges', () => {
const buckets = [
{ month: '1914-08', count: 1 },
{ month: '1914-09', count: 1 },
{ month: '1914-10', count: 1 },
{ month: '1914-11', count: 1 },
{ month: '1914-12', count: 1 },
{ month: '1915-01', count: 1 },
{ month: '1915-02', count: 1 },
{ month: '1915-03', count: 1 },
{ month: '1915-04', count: 1 },
{ month: '1915-05', count: 1 },
{ month: '1915-06', count: 1 },
{ month: '1915-07', count: 1 },
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 1 },
{ month: '1915-10', count: 1 },
{ month: '1915-11', count: 1 },
{ month: '1915-12', count: 1 },
{ month: '1916-01', count: 1 },
{ month: '1916-02', count: 1 }
];
const ticks = tickIndicesFor(buckets);
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
});
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
const buckets = Array.from({ length: 12 }, (_, i) => ({
month: `1905-${String(i + 1).padStart(2, '0')}`,
count: 1
}));
const ticks = tickIndicesFor(buckets);
expect(ticks.length).toBeGreaterThanOrEqual(5);
expect(ticks.length).toBeLessThanOrEqual(7);
expect(ticks[0]).toBe(0);
});
});
describe('formatTickLabel', () => {
it('returns the year string unchanged for year labels', () => {
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
});
it('formats month labels with the year by default', () => {
const result = formatTickLabel('1905-06', 'en-US');
expect(result).toMatch(/Jun/);
expect(result).toMatch(/1905/);
});
it('omits the year when omitYear is true', () => {
const result = formatTickLabel('1905-06', 'en-US', true);
expect(result).toMatch(/Jun/);
expect(result).not.toMatch(/1905/);
});
});

View File

@@ -0,0 +1,163 @@
import type { components } from '$lib/generated/api';
/**
* Pure month-bucket math shared by the document density chart (`lib/document/`)
* and the global timeline strip (`lib/timeline/`). Reuses the generated
* `MonthBucket` schema type so both surfaces stay coupled to the backend shape.
* No I/O, no DOM — relocated here so `lib/timeline/` never imports `lib/document/`.
*/
export type MonthBucket = components['schemas']['MonthBucket'];
export function monthBoundaryFrom(yearMonth: string): string {
return `${yearMonth}-01`;
}
export function monthBoundaryTo(yearMonth: string): string {
const [year, month] = yearMonth.split('-').map(Number);
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
}
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
if (!minDate || !maxDate) return [];
const [minY, minM] = minDate.split('-').map(Number);
const [maxY, maxM] = maxDate.split('-').map(Number);
const sequence: string[] = [];
let year = minY;
let month = minM;
while (year < maxY || (year === maxY && month <= maxM)) {
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return sequence;
}
export function fillDensityGaps(
buckets: MonthBucket[],
minDate: string | null,
maxDate: string | null
): MonthBucket[] {
const sequence = buildMonthSequence(minDate, maxDate);
if (sequence.length === 0) return [];
const counts = new Map(buckets.map((b) => [b.month, b.count]));
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* Returns only the month buckets whose YYYY-MM falls inside the provided
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
* input array is returned unchanged. Used by the timeline's zoom-in tool to
* narrow the visible bars without refetching data.
*
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
* unit suite (`monthBuckets.spec.ts`) can pin the boundary semantics directly.
*/
export function clipBucketsToRange(
buckets: MonthBucket[],
fromInclusive: string | null,
toInclusive: string | null
): MonthBucket[] {
if (!fromInclusive || !toInclusive) return buckets;
const fromMonth = fromInclusive.slice(0, 7);
const toMonth = toInclusive.slice(0, 7);
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
}
/**
* Aggregates month-granular buckets into one entry per year. Month strings are
* truncated to "YYYY" and counts are summed. Used when the date span is too
* long for month-granular bars to render at a clickable size.
*/
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
const totals = new Map<string, number>();
for (const b of buckets) {
const year = b.month.slice(0, 4);
totals.set(year, (totals.get(year) ?? 0) + b.count);
}
return Array.from(totals.entries())
.map(([year, count]) => ({ month: year, count }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
* (year) and return the matching LocalDate string.
*/
export function selectionBoundaryFrom(label: string): string {
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
}
export function selectionBoundaryTo(label: string): string {
if (label.length === 4) return `${label}-12-31`;
return monthBoundaryTo(label);
}
/**
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
* to whether bars are years or months and how many are visible:
* - Year bars: pick years divisible by a step that scales with range length
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
* one year zoomed in to months), fall back to evenly spaced ticks so we
* show ~6 labels even when no January boundary exists.
*/
export function tickIndicesFor(filled: MonthBucket[]): number[] {
if (filled.length === 0) return [];
const isYearMode = filled[0].month.length === 4;
const indices: number[] = [];
if (isYearMode) {
const years = filled.length;
const step =
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
for (let i = 0; i < filled.length; i++) {
const year = parseInt(filled[i].month, 10);
if (year % step === 0) indices.push(i);
}
return indices;
}
if (filled.length <= 18) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
return indices;
}
// Long month range — pick January boundaries (year breaks).
for (let i = 0; i < filled.length; i++) {
if (filled[i].month.endsWith('-01')) indices.push(i);
}
// Fallback if there's no January in the visible range (rare): even spacing.
if (indices.length === 0) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
}
return indices;
}
/**
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
* "Jan", "Feb", … without repetition.
*/
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
if (label.length === 4) return label;
const [yearStr, monthStr] = label.split('-');
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
const opts: Intl.DateTimeFormatOptions = omitYear
? { month: 'short' }
: { month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(locale, opts).format(date);
}

View File

@@ -0,0 +1,59 @@
<script lang="ts">
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 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 }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const canEdit = $derived(!entry.derived && entry.eventId != null);
</script>
<div class="flex justify-center">
<div
class="inline-flex items-center gap-2 rounded-full bg-surface px-3 py-1 shadow-sm {config.accent ===
'curated'
? 'border-2 border-brand-mint'
: 'border border-brand-navy'}"
>
<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}
{#if dateLabel}
<span class="block font-sans text-xs text-ink-3">{dateLabel}</span>
{/if}
</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

@@ -0,0 +1,90 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import EventPill from './EventPill.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
const EVENT_ID = '33333333-3333-3333-3333-333333333333';
function derived(derivedType: 'BIRTH' | 'DEATH' | 'MARRIAGE', title: string) {
return makeEntry({
kind: 'EVENT',
derived: true,
derivedType,
title,
senderName: '',
receiverName: '',
precision: 'YEAR',
eventDate: '1914-01-01',
documentId: undefined
});
}
describe('EventPill', () => {
it('renders a derived marriage as ⚭ + "Heirat" + title (REQ-007)', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat: Karl & Elfriede') });
expect(document.body.textContent).toContain('⚭');
expect(document.body.textContent).toContain('Heirat');
expect(document.body.textContent).toContain('Heirat: Karl & Elfriede');
});
it('renders a derived birth as * + "Geburt" (REQ-007)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
expect(document.body.textContent).toContain('*');
expect(document.body.textContent).toContain('Geburt');
});
it('renders a derived death as † + "Tod" (REQ-007)', () => {
render(EventPill, { entry: derived('DEATH', 'Tod: Karl') });
expect(document.body.textContent).toContain('†');
expect(document.body.textContent).toContain('Tod');
});
it('wraps the glyph aria-hidden with an sr-only label sibling (REQ-018)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('*');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Geburt');
});
it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
render(EventPill, {
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: EVENT_ID,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
expect(edit).not.toBeNull();
expect(edit?.getAttribute('href')).toContain(EVENT_ID);
});
it('shows no edit affordance when eventId is null (REQ-008)', () => {
render(EventPill, {
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: undefined,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('shows no edit affordance for a derived event (REQ-008)', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
/**
* A folded run of fully-empty interior years (REQ-015), rendered as a thin
* dashed span so the scroll stays oriented. Collapses to a single year when the
* run has length 1.
*/
let { from, to }: { from: number; to: number } = $props();
const yearLabel = $derived(from === to ? `${from}` : `${from}${to}`);
</script>
<div
class="mx-auto my-2 flex max-w-md items-center gap-2 rounded-full border border-dashed border-line bg-canvas px-4 py-1 font-sans text-xs text-ink-3"
>
<span class="h-px flex-1 bg-line"></span>
<span><span class="font-serif text-ink-2">{yearLabel}</span> · {m.timeline_gap_empty()}</span>
<span class="h-px flex-1 bg-line"></span>
</div>

View File

@@ -0,0 +1,20 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import GapSpan from './GapSpan.svelte';
afterEach(() => cleanup());
describe('GapSpan', () => {
it('renders a multi-year empty run as "{from}{to} · keine Einträge" (REQ-015)', () => {
render(GapSpan, { from: 1910, to: 1914 });
expect(document.body.textContent).toContain('19101914');
expect(document.body.textContent).toContain('keine Einträge');
});
it('renders a single empty year as "{year} · keine Einträge" (REQ-015)', () => {
render(GapSpan, { from: 1912, to: 1912 });
expect(document.body.textContent).toContain('1912');
expect(document.body.textContent).not.toContain('19121912');
expect(document.body.textContent).toContain('keine Einträge');
});
});

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
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-021); never the raw-HTML directive.
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
);
</script>
<!-- Box layout inline (not just utility classes) so the 44px touch target holds
even before the stylesheet loads — an <a> is inline by default and would
ignore min-height otherwise. WCAG 2.5.5 (REQ-020). -->
<a
href="/documents/{entry.documentId}"
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{#if entry.title}
<span class="font-serif text-sm font-bold break-words whitespace-pre-line text-ink"
>{entry.title}</span
>
{/if}
<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}
<span data-testid="letter-date"> · {dateLabel}</span>
{/if}
</span>
</a>

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import LetterCard from './LetterCard.svelte';
import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
const DOC_ID = '22222222-2222-2222-2222-222222222222';
describe('LetterCard', () => {
it('renders sender, receiver, and title', () => {
render(LetterCard, {
entry: makeEntry({ senderName: 'Karl', receiverName: 'Elfriede', title: 'Feldpost' })
});
expect(document.body.textContent).toContain('Karl');
expect(document.body.textContent).toContain('Elfriede');
expect(document.body.textContent).toContain('Feldpost');
});
it('renders the precision date exactly as timelineDateLabel returns (REQ-013)', () => {
const entry = makeEntry({ eventDate: '1915-06-15', precision: 'MONTH' });
const expected = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
expect(expected).toBeTruthy();
render(LetterCard, { entry });
expect(document.body.textContent).toContain(expected as string);
});
it('renders no date chip when timelineDateLabel returns null (REQ-013)', () => {
const entry = makeEntry({ precision: 'UNKNOWN', eventDate: undefined });
render(LetterCard, { entry });
const chip = document.querySelector('[data-testid="letter-date"]');
expect(chip).toBeNull();
});
it('shows "Unbekannt" for an empty sender, never a bare arrow (REQ-014)', () => {
render(LetterCard, { entry: makeEntry({ senderName: '', receiverName: 'Elfriede' }) });
expect(document.body.textContent).toContain('Unbekannt');
});
it('shows "Unbekannt" for an empty receiver (REQ-014)', () => {
render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: '' }) });
expect(document.body.textContent).toContain('Unbekannt');
});
it('links to exactly /documents/{documentId} with no target (REQ-023)', () => {
render(LetterCard, { entry: makeEntry({ documentId: DOC_ID }) });
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
expect(link.hasAttribute('target')).toBe(false);
});
it('has a touch target of at least 44px (REQ-020)', () => {
render(LetterCard, { entry: makeEntry() });
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
});

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import YearBand from './YearBand.svelte';
import GapSpan from './GapSpan.svelte';
import LetterCard from './LetterCard.svelte';
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import { entryKey } from './entryKey';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
/**
* Orchestrates the global timeline (REQ-001/003). Renders the year bands the DTO
* delivers in order — never re-sorting — interleaving a folded GapSpan for each
* interior run of empty years (REQ-015), then the undated bucket (REQ-016). An
* 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.
*/
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
const years = timeline.years;
for (let i = 0; i < years.length; i++) {
if (i > 0) {
const prev = years[i - 1].year;
const cur = years[i].year;
if (cur - prev > 1) out.push({ t: 'gap', from: prev + 1, to: cur - 1 });
}
out.push({ t: 'band', year: years[i] });
}
return out;
});
const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length === 0);
</script>
{#if isEmpty}
<p class="py-12 text-center font-serif text-base text-ink-2">{m.timeline_empty_state()}</p>
{:else}
<!-- personId is a declared seam for the per-person Lebensweg rail (issue #10);
undefined in the global view, surfaced only on the root, never passed to
leaf cards (REQ-025). -->
<ol class="timeline-axis relative mx-auto max-w-3xl" data-person-id={personId}>
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
<li>
{#if row.t === 'band'}
<YearBand year={row.year} />
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}
</li>
{/each}
</ol>
{#if timeline.undated.length > 0}
<section data-testid="undated-section" class="mx-auto mt-8 max-w-3xl">
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">{m.timeline_undated_section()}</h2>
<ul class="space-y-2">
<!-- The undated bucket is filtered from ALL entries, so it can hold
events as well as letters. Dispatch on kind/type exactly like
YearBand — an event rendered as a LetterCard would link to
/documents/undefined and read "Unknown → Unknown" (REQ-007/008/009). -->
{#each timeline.undated as entry (entryKey(entry))}
<li>
{#if entry.kind === 'EVENT'}
{#if entry.type === 'HISTORICAL'}
<WorldBand entry={entry} />
{:else}
<EventPill entry={entry} />
{/if}
{:else}
<LetterCard entry={entry} />
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<style>
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
centered spine the bands' alternating cards sit on either side of. The
spine is decorative — the chronology lives in the <ol> DOM order. */
.timeline-axis::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0.5rem;
width: 2px;
background: linear-gradient(var(--palette-mint), var(--palette-navy));
}
@media (min-width: 1024px) {
.timeline-axis::before {
left: 50%;
transform: translateX(-50%);
}
}
</style>

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TimelineView from './TimelineView.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
afterEach(() => cleanup());
describe('TimelineView', () => {
it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => {
render(TimelineView, { timeline: makeTimelineDTO() });
expect(document.body.textContent).toContain('Noch keine Ereignisse.');
expect(document.querySelector('ol')).toBeNull();
expect(document.querySelector('section')).toBeNull();
});
it('renders the timeline as a single <ol> with each band a <section>, ascending (REQ-006)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [makeEntry({ documentId: 'a' })]),
makeYear(1916, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.querySelectorAll('ol')).toHaveLength(1);
const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent);
expect(headings.some((t) => t?.includes('1914'))).toBe(true);
const order = headings.map((t) => t?.trim());
expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916'));
});
it('folds an interior run of empty years into one GapSpan (REQ-015)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1909, [makeEntry({ documentId: 'a' })]),
makeYear(1915, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.body.textContent).toContain('19101914');
expect(document.body.textContent).toContain('keine Einträge');
});
it('folds a single empty interior year as a single year (REQ-015)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1911, [makeEntry({ documentId: 'a' })]),
makeYear(1913, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.body.textContent).toContain('1912');
expect(document.body.textContent).not.toContain('19121912');
});
it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [makeYear(1914, [makeEntry({ documentId: 'a' })])],
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
})
});
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Ohne Datum');
});
it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] })
});
expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
});
it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [makeEntry({ documentId: 'a' })]),
makeYear(1915, [makeEntry({ documentId: 'b' })])
],
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
}),
personId: undefined
});
// Two year bands inside the <ol>, plus the separate undated section.
expect(document.querySelectorAll('ol section h2')).toHaveLength(2);
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
});
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: 'e1',
precision: 'UNKNOWN',
eventDate: undefined,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
]
})
});
// The event renders inside the undated section…
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Auswanderung');
// …as an EventPill (its edit affordance), never as a letter card linking
// to /documents/undefined with "Unbekannt → Unbekannt".
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
expect(document.body.textContent).not.toContain('Unbekannt');
});
it('renders an undated HISTORICAL EVENT as a world band, not a letter card (REQ-009/016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
precision: 'UNKNOWN',
eventDate: undefined,
title: 'Weltwirtschaftskrise',
senderName: '',
receiverName: '',
documentId: undefined
})
]
})
});
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Weltwirtschaftskrise');
// HISTORICAL → WorldBand carries the sr-only "Weltgeschehen" cue (REQ-018),
// not a broken document link.
expect(document.body.textContent).toContain('Weltgeschehen');
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
});
it('still renders an undated LETTER as a letter card (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
})
});
expect(document.querySelector('a[href="/documents/u1"]')).not.toBeNull();
});
it('renders two derived events in one band without key collision (no-double-null-key)', () => {
const a = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Anna',
senderName: '',
receiverName: '',
documentId: undefined,
eventId: undefined,
linkedPersonIds: ['p1']
});
const b = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Bertha',
senderName: '',
receiverName: '',
documentId: undefined,
eventId: undefined,
linkedPersonIds: ['p2']
});
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) });
expect(document.body.textContent).toContain('Geburt: Anna');
expect(document.body.textContent).toContain('Geburt: Bertha');
});
it('shows the redundant non-color cue label for each layer (REQ-018)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [
makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Hans',
senderName: '',
receiverName: '',
documentId: undefined
}),
makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: 'e1',
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
}),
makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
documentId: undefined
})
])
]
})
});
expect(document.body.textContent).toContain('Weltgeschehen');
expect(document.body.textContent).toContain('Familie');
expect(document.body.textContent).toContain('Geburt');
});
it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) =>
el.getAttribute('data-side')
);
expect(sides).toEqual(['left', 'right', 'left', 'right']);
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
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'];
/**
* Full-width muted band for a HISTORICAL event, laid across the axis as context
* (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).
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const fromYear = $derived(entry.eventDate ? entry.eventDate.slice(0, 4) : null);
const toYear = $derived(entry.eventDateEnd ? entry.eventDateEnd.slice(0, 4) : null);
const showSpan = $derived(entry.precision === 'RANGE' && fromYear != null && toYear != null);
const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYear : dateLabel);
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
<span class="font-serif text-sm text-ink-2 italic">
<span aria-hidden="true" style="color: var(--c-tag-slate)">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
{entry.title}
</span>
{#if showSpan && fromYear && toYear}
<span
data-testid="world-range"
class="ml-2 inline-block rounded-full border border-line px-2 py-0.5 font-sans text-xs text-ink-2"
aria-label={m.timeline_range_aria({ from: fromYear, to: toYear })}
>
{fromYear}{toYear}
</span>
{:else if dateText}
<span class="ml-2 font-sans text-xs text-ink-3">{dateText}</span>
{/if}
</div>

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import WorldBand from './WorldBand.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
function historical(overrides = {}) {
return makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
documentId: undefined,
...overrides
});
}
describe('WorldBand', () => {
it('renders the historical title with the world glyph + "Weltgeschehen" cue (REQ-018)', () => {
render(WorldBand, { entry: historical() });
expect(document.body.textContent).toContain('Erster Weltkrieg');
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('◍');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Weltgeschehen');
});
it('renders a RANGE span pill 19141918 with a Zeitraum aria-label (REQ-009)', () => {
render(WorldBand, { entry: historical() });
const pill = document.querySelector('[data-testid="world-range"]');
expect(pill).not.toBeNull();
expect(pill?.textContent).toContain('19141918');
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
});
it('degrades a RANGE with no end to the start year, no span pill, no crash (REQ-010)', () => {
render(WorldBand, { entry: historical({ eventDateEnd: undefined }) });
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain('Erster Weltkrieg');
expect(document.body.textContent).toContain('1914');
});
});

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
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/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 }: { year: TimelineYearDTO } = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' };
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
const dense = $derived(isDense(letters.length));
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) {
if (entry.kind === 'EVENT') {
out.push({ t: 'event', entry });
} else if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
}
}
return out;
});
</script>
<section class="py-2">
<h2
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
>
{year.year}
</h2>
<div class="mt-3 space-y-3">
{#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} />
{:else}
<EventPill entry={row.entry} />
{/if}
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>
<LetterCard entry={row.entry} />
</div>
{:else}
<YearLetterStrip letters={letters} year={year.year} />
{/if}
{/each}
</div>
</section>
<style>
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
header is a 64px sticky nav). REQ-006. */
.year-heading {
position: sticky;
top: 4rem;
z-index: 1;
}
/* Phone (< 1024px): single left-anchored column, all letters on one side
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
so consecutive cards sit on opposite sides of the spine (REQ-004). */
@media (min-width: 1024px) {
.letter-row {
width: 50%;
}
.letter-row[data-side='left'] {
margin-right: auto;
padding-right: 1.75rem;
}
.letter-row[data-side='right'] {
margin-left: auto;
padding-left: 1.75rem;
}
}
</style>

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import YearBand from './YearBand.svelte';
import { makeEntry, makeYear } from './test-factories';
afterEach(() => cleanup());
function manyLetters(year: number, count: number) {
return Array.from({ length: count }, (_, i) =>
makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` })
);
}
describe('YearBand', () => {
it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => {
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const section = document.querySelector('section');
expect(section).not.toBeNull();
const h2 = section?.querySelector('h2');
expect(h2?.textContent).toContain('1914');
const cs = getComputedStyle(h2 as HTMLElement);
expect(cs.position).toBe('sticky');
expect(cs.top).toBe('64px');
});
it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => {
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
expect(document.querySelectorAll('a')).toHaveLength(3);
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
});
it('renders a single strip when the band holds > 12 letters (REQ-012)', () => {
render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) });
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
// collapsed: no individual letter links yet
expect(document.querySelectorAll('a')).toHaveLength(0);
});
it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => {
const dayLetter = makeEntry({
precision: 'DAY',
eventDate: '1923-04-12',
title: 'Tagesgenau',
documentId: 'day'
});
const yearLetter = makeEntry({
precision: 'YEAR',
eventDate: '1923-01-01',
title: 'Nur Jahr',
documentId: 'year'
});
render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) });
const links = Array.from(document.querySelectorAll('a'));
expect(links[0].getAttribute('href')).toBe('/documents/day');
expect(links[1].getAttribute('href')).toBe('/documents/year');
});
it('renders an EVENT as a pill and a HISTORICAL event as a band', () => {
const pill = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'MARRIAGE',
title: 'Heirat',
senderName: '',
receiverName: '',
documentId: undefined
});
const band = makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
documentId: undefined
});
render(YearBand, { year: makeYear(1914, [pill, band]) });
expect(document.body.textContent).toContain('Heirat');
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
});
});

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import Sparkline from '$lib/shared/primitives/Sparkline.svelte';
import LetterCard from './LetterCard.svelte';
import { monthHistogram } from './timelineDensity';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Compact density view for a year with many letters (REQ-012): the letter count
* plus a 12-month density sparkline, and a ≥44px keyboard-focusable toggle that
* expands to that year's individual LetterCards.
*/
let { letters, year }: { letters: TimelineEntryDTO[]; year: number } = $props();
let expanded = $state(false);
const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
</script>
<div class="mx-auto max-w-md rounded-sm border border-line bg-surface p-3 shadow-sm">
<div class="flex items-center justify-between gap-3">
<span class="font-sans text-sm font-bold text-brand-navy"
>{m.timeline_letters_count({ count: letters.length })}</span
>
<button
type="button"
data-testid="strip-expand"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
style="display: inline-flex; align-items: center; min-height: 44px"
class="rounded-sm px-2 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{m.timeline_strip_expand()}
</button>
</div>
<Sparkline
values={counts}
label={m.timeline_letters_count({ count: letters.length })}
class="mt-2"
/>
{#if expanded}
<ul class="mt-3 space-y-2">
{#each letters as letter (letter.documentId)}
<li><LetterCard entry={letter} /></li>
{/each}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,42 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
function denseLetters(year: number, count: number) {
return Array.from({ length: count }, (_, i) =>
makeEntry({
eventDate: `${year}-${String((i % 12) + 1).padStart(2, '0')}-10`,
documentId: `doc-${i}`
})
);
}
describe('YearLetterStrip', () => {
it('shows the letter count and a 12-bar sparkline (REQ-012)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
expect(document.body.textContent).toContain('30');
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
expect(bars).toHaveLength(12);
});
it('has a keyboard-focusable expand toggle of at least 44px (REQ-012)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
expect(toggle).not.toBeNull();
expect(toggle.tagName).toBe('BUTTON');
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it('reveals all letter cards when expanded (REQ-012)', async () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
expect(document.querySelectorAll('a').length).toBe(0);
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
toggle.click();
await tick();
expect(document.querySelectorAll('a').length).toBe(30);
});
});

View File

@@ -0,0 +1,23 @@
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Stable `{#each}` key for a timeline entry. Prefers the entry's own identity
* (`eventId` for curated events, `documentId` for letters); derived life-events
* carry neither, so they key on `derivedType` + their linked person ids — which
* keeps two derived births in the same year distinct. The `kind` prefix keeps an
* event and a letter that happen to share an id from colliding.
*
* Used by both `YearBand` (per-band rows) and `TimelineView` (the undated
* bucket), where entries can be events without a `documentId`.
*/
export function entryKey(entry: TimelineEntryDTO): string {
return (
entry.kind +
':' +
(entry.eventId ??
entry.documentId ??
`${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`)
);
}

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { getAccentConfig } from './eventCardConfig';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
function event(overrides: Partial<TimelineEntryDTO>): TimelineEntryDTO {
return {
kind: 'EVENT',
precision: 'YEAR',
derived: false,
senderName: '',
receiverName: '',
...overrides
};
}
describe('getAccentConfig', () => {
it('maps a derived birth to the * glyph and "Geburt"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'BIRTH' }));
expect(cfg.glyph).toBe('*');
expect(cfg.label).toBe('Geburt');
expect(cfg.accent).toBe('derived');
});
it('maps a derived death to the † glyph and "Tod"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'DEATH' }));
expect(cfg.glyph).toBe('†');
expect(cfg.label).toBe('Tod');
expect(cfg.accent).toBe('derived');
});
it('maps a derived marriage to the ⚭ glyph and "Heirat"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'MARRIAGE' }));
expect(cfg.glyph).toBe('⚭');
expect(cfg.label).toBe('Heirat');
expect(cfg.accent).toBe('derived');
});
it('maps a HISTORICAL event to the world glyph and "Weltgeschehen"', () => {
const cfg = getAccentConfig(event({ type: 'HISTORICAL' }));
expect(cfg.glyph).toBe('◍');
expect(cfg.label).toBe('Weltgeschehen');
expect(cfg.accent).toBe('historical');
});
it('maps a curated PERSONAL event to the ★ glyph and "Familie"', () => {
const cfg = getAccentConfig(event({ type: 'PERSONAL', eventId: 'e-1' }));
expect(cfg.glyph).toBe('★');
expect(cfg.label).toBe('Familie');
expect(cfg.accent).toBe('curated');
});
});

View File

@@ -0,0 +1,38 @@
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/** Styling discriminant for an axis pill/band. */
export type TimelineAccent = 'derived' | 'curated' | 'historical';
export interface AccentConfig {
/** Visible Unicode glyph — render `aria-hidden`, paired with an sr-only label. */
glyph: string;
/** Localized layer/life-event label — used as the sr-only / aria text only. */
label: string;
accent: TimelineAccent;
}
/**
* Maps a timeline EVENT entry to its glyph, redundant non-color label, and accent
* (REQ-007/008/018). Derived life-events use the * / † / ⚭ glyphs that match
* `personLifeDates.ts`; HISTORICAL events get the muted world band; everything
* else (curated PERSONAL) gets the mint family pill.
*/
export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
if (entry.derived) {
switch (entry.derivedType) {
case 'BIRTH':
return { glyph: '*', label: m.timeline_derived_birth(), accent: 'derived' };
case 'DEATH':
return { glyph: '†', label: m.timeline_derived_death(), accent: 'derived' };
case 'MARRIAGE':
return { glyph: '⚭', label: m.timeline_derived_marriage(), accent: 'derived' };
}
}
if (entry.type === 'HISTORICAL') {
return { glyph: '◍', label: m.timeline_layer_world(), accent: 'historical' };
}
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
}

View File

@@ -0,0 +1,34 @@
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
type TimelineDTO = components['schemas']['TimelineDTO'];
/**
* Builds a `TimelineEntryDTO` mirroring the real wire shape (no `year`,
* `description`, or `snippet` fields). Defaults to a dated DAY-precision letter;
* override `kind`/`derived`/`type`/`derivedType` etc. for events.
*/
export function makeEntry(overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO {
return {
kind: 'LETTER',
precision: 'DAY',
derived: false,
senderName: 'Karl Raddatz',
receiverName: 'Elfriede Raddatz',
eventDate: '1915-06-15',
title: 'Brief aus dem Feld',
documentId: '11111111-1111-1111-1111-111111111111',
...overrides
};
}
export function makeYear(year: number, entries: TimelineEntryDTO[]): TimelineYearDTO {
return { year, entries };
}
export function makeTimelineDTO(
opts: { years?: TimelineYearDTO[]; undated?: TimelineEntryDTO[] } = {}
): TimelineDTO {
return { years: opts.years ?? [], undated: opts.undated ?? [] };
}

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { isDense, monthHistogram, DENSE_THRESHOLD } from './timelineDensity';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
function letter(eventDate: string): TimelineEntryDTO {
return {
kind: 'LETTER',
precision: 'DAY',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede',
eventDate
};
}
describe('isDense', () => {
it('uses a threshold of 12', () => {
expect(DENSE_THRESHOLD).toBe(12);
});
it('is false at exactly 12 letters (still rendered as individual cards)', () => {
expect(isDense(12)).toBe(false);
});
it('is true above 12 letters (collapses to a strip)', () => {
expect(isDense(13)).toBe(true);
});
it('is false for empty and small bands', () => {
expect(isDense(0)).toBe(false);
expect(isDense(3)).toBe(false);
});
});
describe('monthHistogram', () => {
it('returns exactly 12 buckets for the band year, Jan..Dec', () => {
const buckets = monthHistogram([letter('1915-03-04')], 1915);
expect(buckets).toHaveLength(12);
expect(buckets.map((b) => b.month)).toEqual([
'1915-01',
'1915-02',
'1915-03',
'1915-04',
'1915-05',
'1915-06',
'1915-07',
'1915-08',
'1915-09',
'1915-10',
'1915-11',
'1915-12'
]);
});
it('counts each letter on its eventDate month; counts sum to the total', () => {
// 30 letters spread one-or-more per month across 1915.
const dist: Record<string, number> = {
'01': 1,
'02': 2,
'03': 3,
'04': 4,
'05': 1,
'06': 5,
'07': 2,
'08': 6,
'09': 1,
'10': 2,
'11': 2,
'12': 1
};
const letters: TimelineEntryDTO[] = [];
for (const [mm, n] of Object.entries(dist)) {
for (let i = 0; i < n; i++) letters.push(letter(`1915-${mm}-10`));
}
expect(letters).toHaveLength(30);
const buckets = monthHistogram(letters, 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(30);
for (const b of buckets) {
expect(b.count).toBe(dist[b.month.slice(5)]);
}
});
it('yields height 0 for the eleven empty months when letters cluster in one', () => {
const buckets = monthHistogram([letter('1915-03-01'), letter('1915-03-28')], 1915);
const march = buckets.find((b) => b.month === '1915-03');
expect(march?.count).toBe(2);
expect(buckets.filter((b) => b.month !== '1915-03').every((b) => b.count === 0)).toBe(true);
});
it('counts coarser-than-month precisions on their eventDate anchor month', () => {
const seasonLetter: TimelineEntryDTO = { ...letter('1915-07-01'), precision: 'SEASON' };
const buckets = monthHistogram([seasonLetter], 1915);
expect(buckets.find((b) => b.month === '1915-07')?.count).toBe(1);
});
it('ignores entries without an eventDate', () => {
const undated: TimelineEntryDTO = {
kind: 'LETTER',
precision: 'UNKNOWN',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede'
};
const buckets = monthHistogram([undated, letter('1915-05-01')], 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(1);
});
});

View File

@@ -0,0 +1,32 @@
import type { components } from '$lib/generated/api';
import { fillDensityGaps, type MonthBucket } from '$lib/shared/utils/monthBuckets';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A year band with more letters than this renders as a compact density strip
* (count + 12-month sparkline) instead of one card per letter (REQ-012).
*/
export const DENSE_THRESHOLD = 12;
export function isDense(letterCount: number): boolean {
return letterCount > DENSE_THRESHOLD;
}
/**
* Buckets a band's letters into exactly 12 month buckets (`{year}-01`..`{year}-12`)
* for the density sparkline. Each letter counts on its `eventDate` month; coarser
* precisions (SEASON/YEAR/APPROX) count on whatever anchor month the backend put
* in `eventDate`. Entries without an `eventDate` (e.g. UNKNOWN) are ignored — they
* live in the "Ohne Datum" bucket, not a dated band. (REQ-027)
*/
export function monthHistogram(letters: TimelineEntryDTO[], year: number): MonthBucket[] {
const counts = new Map<string, number>();
for (const l of letters) {
if (!l.eventDate) continue;
const month = l.eventDate.slice(0, 7); // YYYY-MM
counts.set(month, (counts.get(month) ?? 0) + 1);
}
const buckets = Array.from(counts.entries()).map(([month, count]) => ({ month, count }));
return fillDensityGaps(buckets, `${year}-01-01`, `${year}-12-31`);
}

View File

@@ -78,6 +78,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
> >
{m.nav_geschichten()} {m.nav_geschichten()}
</a> </a>
<a
href="/zeitstrahl"
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/zeitstrahl')
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_zeitstrahl()}
</a>
{#if isAdmin} {#if isAdmin}
<a <a
href="/admin" href="/admin"
@@ -190,6 +200,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
{m.nav_geschichten()} {m.nav_geschichten()}
</a> </a>
<a
href="/zeitstrahl"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/zeitstrahl')
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_zeitstrahl()}
</a>
{#if isAdmin} {#if isAdmin}
<a <a
href="/admin" href="/admin"

View File

@@ -0,0 +1,19 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
// Global timeline: personId is undefined, so no query params (REQ-001). SSR-first
// via createApiClient so the session cookie is forwarded; no client-side fetch
// (REQ-002). The raw payload (correspondent names/titles) is PII — never logged.
export async function load({ fetch }) {
const api = createApiClient(fetch);
const result = await api.GET('/api/timeline');
if (result.response.status === 401) throw redirect(302, '/login');
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
}
return { timeline: result.data! };
}

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{m.timeline_heading()}</title>
</svelte:head>
<div class="mx-auto max-w-5xl px-4 py-8">
<h1 class="mb-8 font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
<TimelineView timeline={data.timeline} />
</div>

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
beforeEach(() => vi.clearAllMocks());
const TIMELINE = { years: [{ year: 1914, entries: [] }], undated: [] };
function mockApi(opts: { ok?: boolean; status?: number; data?: unknown; error?: unknown }) {
const { ok = true, status = 200, data = TIMELINE, error } = opts;
const GET = vi.fn().mockResolvedValue({
response: { ok, status },
data: ok ? data : undefined,
error
});
vi.mocked(createApiClient).mockReturnValue({ GET } as unknown as ReturnType<
typeof createApiClient
>);
return GET;
}
function callLoad() {
return load({
fetch: vi.fn() as unknown as typeof fetch,
url: new URL('http://localhost/zeitstrahl'),
request: new Request('http://localhost/zeitstrahl'),
route: { id: '/zeitstrahl' },
params: {}
} as unknown as Parameters<typeof load>[0]);
}
describe('zeitstrahl +page.server load', () => {
it('fetches GET /api/timeline and returns { timeline } on ok (REQ-001/002)', async () => {
const GET = mockApi({ data: TIMELINE });
const result = await callLoad();
expect(GET).toHaveBeenCalledWith('/api/timeline');
expect(result).toEqual({ timeline: TIMELINE });
});
it('redirects to /login on 401 (REQ-022)', async () => {
mockApi({ ok: false, status: 401 });
await expect(callLoad()).rejects.toMatchObject({ status: 302, location: '/login' });
});
it('throws a mapped error on 404 (REQ-022)', async () => {
mockApi({ ok: false, status: 404, error: { code: 'TIMELINE_EVENT_NOT_FOUND' } });
await expect(callLoad()).rejects.toMatchObject({
status: 404,
body: { message: getErrorMessage('TIMELINE_EVENT_NOT_FOUND') }
});
});
it('throws a mapped error on 500 (REQ-022)', async () => {
mockApi({ ok: false, status: 500, error: undefined });
await expect(callLoad()).rejects.toMatchObject({ status: 500 });
});
it('throws a mapped FORBIDDEN error on 403 (REQ-022)', async () => {
mockApi({ ok: false, status: 403, error: { code: 'FORBIDDEN' } });
await expect(callLoad()).rejects.toMatchObject({
status: 403,
body: { message: getErrorMessage('FORBIDDEN') }
});
});
});