Compare commits

..

111 Commits

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

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

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

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

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

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

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

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

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

REQ-015

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

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

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

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

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

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

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

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:19:07 +02:00
Marcel
1cd6ffd5ca refactor(timeline): de-duplicate the TagChip markup
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m4s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m26s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
CI / Unit & Component Tests (push) Successful in 4m21s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 4m57s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 30s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
Two cleanups flagged in review, both behaviour-preserving:
- collapse the {#if color}/{:else} square-marker branches (identical but for the
  neutral fill) into one element via class:bg-ink-3={!color}; squareStyle is
  already empty when color is null, so no var(--c-tag-) leaks into the neutral
  chip.
- drop the redundant `truncate` class from the name span — the inline
  overflow/ellipsis trio (kept so it applies before the stylesheet loads,
  REQ-008a) already expresses exactly what `truncate` would.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:59:10 +02:00
Marcel
095eeeb4d4 fix(tag): warn when a tag's root cannot be resolved
resolveRoot silently falls back to returning the tag itself when no null-parent
ancestor surfaces — an orphaned parent_id or a chain deeper than the
findAncestorIds CTE depth guard. The chip then renders a non-root tag as if it
were the theme, with no trace. Log a warning (UUIDs only, per REQ-014) before
the fallback so the anomaly is diagnosable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:58:45 +02:00
Marcel
cf6a262a7a refactor(timeline): resolve each letter's primary tag once
mapDocument re-ran the alphabetical min() scan over the letter's tag set to
look up its already-resolved root, duplicating the work resolveLetterRootTags
had just done and leaving two independent definitions of "primary tag" that
could silently diverge. Key the resolved-root map by document id and compute
the primary tag exactly once per letter; drop the redundant resolvePrimaryRoot
helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:58:20 +02:00
Marcel
4859c77964 docs(rtm): trace #835 REQ-001..014 to their tests
All checks were successful
SDD Gate / Constitution Impact (pull_request) Successful in 16s
CI / Unit & Component Tests (pull_request) Successful in 4m21s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 5m0s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / RTM Check (pull_request) Successful in 16s
Add one row per requirement for the zeitstrahl-tag-chips feature, each mapped
to its implementation file(s) and the test(s) that prove it, Status=Done.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:17:09 +02:00
Marcel
bbf2f96e28 docs(timeline): reword TagChip comment to clear the raw-HTML grep gate
The doc comment described escaping by naming the raw-HTML directive literally,
which trips the lib/timeline grep gate that forbids that token. Reword it the
way LetterCard already does — behavior unchanged.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:16:51 +02:00
Marcel
8376a520c5 feat(timeline): show the root-tag chip on the letter card
LetterCard now renders a TagChip beneath the sender→receiver/date line
whenever the entry carries a rootTagName, mapping rootTagColor to the chip
(neutral when null). Because the chip lives on LetterCard it shows up wherever
a LetterCard does — the global timeline and the expanded YearLetterStrip — with
no per-surface special-casing; a tagless letter shows no chip. A long name
truncates inline so the card never overflows at 320px.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:13:21 +02:00
Marcel
c19d4be3fe feat(timeline): add the root-tag chip component
TagChip renders a letter's primary root tag as a small rounded pill — a
decorative aria-hidden colored square (var(--c-tag-{token}), neutral when the
color is null) plus the escaped tag name, prefixed by the sr-only theme label
so color is never the only cue. Truncation is set inline so a long name
ellipsizes without forcing the card into horizontal scroll, and the full name
stays reachable via the chip title. Timeline-local by design — lib/timeline may
not import lib/tag (eslint boundary).

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:08:51 +02:00
Marcel
90e2b4d6c2 feat(i18n): add the timeline tag-chip theme label
timeline_tag_chip_label (de "Thema" / en "Topic" / es "Tema") is the sr-only
prefix the /zeitstrahl letter tag chip reads out so color is never the only
cue. Pinned per locale in messages.spec.ts; the tag name itself is rendered as
data, never translated.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:08:21 +02:00
Marcel
d33c1e5249 chore(api): regenerate api.ts with the timeline root-tag fields
openapi-typescript pickup of TimelineEntryDTO.rootTagId/rootTagName/
rootTagColor (all optional), so the SvelteKit timeline can read the new
letter chip fields. Regenerated from the live dev spec; only the additive
fields differ from the committed baseline.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:03:16 +02:00
Marcel
1114676ae3 feat(timeline): carry each letter's primary root tag in the DTO
TimelineEntryDTO gains three nullable letter-only fields — rootTagId,
rootTagName, rootTagColor (token) — assembled in-transaction in TimelineService
(ADR-036): id + name + token only, never a serialized Tag entity. A letter's
primary tag is the root ancestor of its alphabetically-first assigned tag
(#827 Resolved Decision 3); roots are resolved through TagService in one
batched pass over the distinct primary tags (no per-letter N+1). The fields are
null for non-letter entries, untagged letters, and (color only) a colorless
root, so they are deliberately not @Schema(requiredMode = REQUIRED).

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:56:18 +02:00
Marcel
0be0a524b3 feat(tag): add a batched root-tag resolver
TagService.resolveRootTags(tags) maps each tag to its root ancestor as a
RootTag (id, name, color token), keyed by the input tag id. A root maps to
itself; a child is walked to the parentless ancestor via the existing
recursive-CTE findAncestorIds — one CTE per distinct non-root tag (memoized),
plus a single batched findAllById — so a timeline of many letters sharing few
tags costs O(distinct tags) queries, never O(letters). The color is read from
the resolved root's stored token (null when the root has none).

This is the shared enrichment the /zeitstrahl tag chip (#835) and, later, the
Thema buckets (#827) both consume. Unit-tested in TagServiceTest; the
DB-dependent ancestry walk is pinned against real Postgres in
TagServiceIntegrationTest.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:47:29 +02:00
Marcel
239565ea20 refactor(timeline): single-source the spine X position via --spine-x
All checks were successful
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 1m9s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 25s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 4m33s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m19s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 26s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / Unit & Component Tests (pull_request) Successful in 4m24s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m21s
The spine offset (0.5rem phone / 50% desktop) was hard-coded in both
TimelineView's .timeline-axis::before and YearBand's .year-node/.letter-dot,
kept in sync only by a comment. Declare --spine-x once on .timeline-axis
and have the markers consume it by inheritance, so a change to the spine
position moves the markers with it. Add a test that the year-node tracks
the token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:51:46 +02:00
Marcel
0a235dc911 refactor(timeline): extract a shared GlyphLabel primitive
The aria-hidden glyph + sr-only label markup was hand-copied in LetterCard
and YearLetterStrip. Extract a small GlyphLabel component and use it at
both sites so the accessibility idiom has a single owner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:48:25 +02:00
Marcel
0bd6790b1f refactor(timeline): count timelineMeta totals in a single pass
Replace the flatMap intermediate array plus two filter passes with one
walk over the year bands and the undated bucket. Same counts, no
throwaway allocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:19:40 +02:00
Marcel
84938e1bf3 refactor(timeline): render the WorldBand historical suffix once
The "· historisch" register was emitted in all three date branches, with
the dateless branch dropping the leading separator. Render the span pill
or date as a conditional prefix, then a single trailing "· historisch"
span — one render site, consistent separator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:17:39 +02:00
Marcel
398babe584 fix(timeline): pluralize the zeitstrahl meta-line counts
A count of one rendered "1 Briefe" / "1 Ereignisse". Add _singular
companion keys (de/en/es) and select them when the count is exactly one,
following the project's _singular/_plural convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:15:11 +02:00
Marcel
9665c9c0fc fix(timeline): drop zero-count segments from the zeitstrahl meta line
A timeline with content in only one category rendered a misleading
"0 Briefe" / "0 Ereignisse" segment. Only push a count segment when its
count is greater than zero; the grouping label and (when present) the
range are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:10:37 +02:00
Marcel
f715f9ce9c fix(timeline): show EventPill provenance even when the event has no date
The provenance token (abgeleitet/kuratiert) was nested inside the
{#if dateLabel} block, so an undated or UNKNOWN-precision event — e.g.
one in the undated bucket — rendered no provenance at all. Compose the
subtitle as an optional "{date} · " prefix in front of the always-present
provenance instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:08:13 +02:00
Marcel
15836ea9ca refactor(timeline): drop the canvas outer border (REQ-001)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m1s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 23s
SDD Gate / RTM Check (pull_request) Successful in 16s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
The page is already bg-canvas, so the frame's border was the only thing
making it visible; per review it reads cleaner without it. Keep the padded
bg-canvas surface; the timeline sits on the page without a frame line.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:39:17 +02:00
Marcel
8029bdec92 fix(timeline): keep the axis spine behind the in-flow content (REQ-006)
The absolutely-positioned spine ::before painted above the in-flow centered
content (density strips, event pills), drawing the line through them. Give
.timeline-axis a stacking context and the spine z-index:-1 so the line is
always background; cards, pills, strips, dots and badges ride on top.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:38:32 +02:00
Marcel
217508ddb2 fix(timeline): keep the year badge above its node marker (REQ-004)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m23s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 5m37s
CI / fail2ban Regex (pull_request) Successful in 53s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
The node marker carried a higher stacking order than the (non-positioned)
badge, so on the centered desktop axis the navy node painted over the white
year digits. Make the badge positioned with the higher z-index; the node now
sits behind the centered pill (which is itself the axis interruption) and
shows only to the badge's left on phone. Guarded by a z-index assertion.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:30:59 +02:00
Marcel
0a4f5c0a9d docs(rtm): mark #833 REQ-001..016 Done with their tests
All sixteen visual-fidelity requirements have a green test (142 timeline +
zeitstrahl + messages tests pass); record the implementation files and the
test that proves each.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:25:42 +02:00
Marcel
2ac4aa8f9c test(timeline): type-clean the zeitstrahl page spec data (REQ-001/002)
Pass the full PageData shape (layout auth fields + timeline) through a
pageData() helper so the route spec is svelte-check-clean; the component
still only reads data.timeline.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:16:07 +02:00
Marcel
bfe66569d7 feat(timeline): paint the axis with a three-stop gradient (REQ-006)
The spine now runs mint → navy → slate, matching the spec's life-thread,
using --palette-mint / --palette-navy / --c-tag-slate (no --palette-slate
token exists). Semantic tokens only — no raw hex.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:10:49 +02:00
Marcel
18934413bb feat(timeline): center the year badge and add spine markers (REQ-003/004/005)
The year badge now centers on the axis at ≥1024px and hugs the left spine
below that (sticky top:4rem preserved), with a navy node marker so it
visibly interrupts the spine. Each letter row gains a connector dot (white
fill, mint ring) on the spine: centered between card and axis on desktop,
on the left spine clear of the indented card on phone. Spine geometry is
commented to track TimelineView's spine so the markers can't silently desync.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:10:05 +02:00
Marcel
e4da28d795 feat(timeline): frame /zeitstrahl in a canvas with a meta line (REQ-001/002)
The timeline now sits inside a bordered, rounded bg-canvas sheet. Below the
heading a sub-line composes the year range, the letter and event counts
(from timelineMeta), and the static "Gruppierung: Datum" — joined by " · "
so the range drops out when there are no year bands and the whole line is
absent for an empty timeline. Semantic tokens only; AA-legible text-xs.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:00:54 +02:00
Marcel
a1e57ff8cf feat(timeline): derive header meta figures from the DTO (REQ-002)
A pure timelineMeta() returns the year range (first/last band, null when
there are no bands) and the letter/event totals across all year bands plus
the undated bucket — the single place these counts are computed.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:58:25 +02:00
Marcel
e0b096f12c feat(timeline): frame the undated bucket with a dashed border + count (REQ-012)
The "Ohne Datum" section now renders inside a dashed-bordered surface box
whose heading reads "Ohne Datum · {count}", matching the spec's .undated
treatment. The kind/type dispatch (events as pills/bands, letters as cards)
is unchanged; the section stays absent when there are no undated entries.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:55:37 +02:00
Marcel
6382efa65a feat(timeline): caption the density strip with ✉ + month axis (REQ-010/011)
The dense-year strip count now carries the ✉ glyph (aria-hidden + sr-only
"Brief"), and beneath the sparkline a "Monats-Dichte" caption sits between
the two endpoint month labels (Jan/Dez {year}) at the ≥10px micro-axis
floor, localized via the shared month formatter. The ≥44px keyboard-
focusable "Briefe anzeigen" expand toggle is preserved unchanged.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:55 +02:00
Marcel
144719720f feat(timeline): add the "· historisch" descriptor to world bands (REQ-009)
A HISTORICAL band's subtitle now carries the visible "historisch" register
inline as plain text: "{date} · historisch", or — for a RANGE — after the
existing 1914–1918 span pill (whose Zeitraum aria-label is unchanged). The
descriptor is a text node, never a second pill. Every WorldBand is
historical, so the suffix also trails an undated band on its own.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:49:19 +02:00
Marcel
fc67dfc3d5 feat(timeline): prefix letter titles with the ✉ glyph (REQ-008/016)
A present LetterCard title now reads "✉ {title}" with an aria-hidden glyph
and an sr-only "Brief" label rendered as sibling nodes — never interpolated
into the escaped user title, which keeps its own pre-line span for
multi-line OCR text. No title → no glyph, no label (the row still shows
sender → receiver and the date). An XSS regression pins the no-{@html}
contract: an HTML-bearing title renders verbatim as text.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:45:47 +02:00
Marcel
08d8896cd1 feat(timeline): show provenance token on the event pill (REQ-007)
The derived/curated pill subtitle now reads "{date} · abgeleitet" or
"{date} · kuratiert", keyed off entry.derived so a reader sees both the
date and whether the event was derived from Person data or curated. Only
the single provenance token ships; the spec sheet's "· persönlich" /
"· SEASON" annotations stay out (already covered by the ★ glyph + mint
border and not production UI).

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:42:33 +02:00
Marcel
808d6efa1a i18n(timeline): add zeitstrahl visual-fidelity strings (REQ-015)
Seven new timeline_* keys feeding the upcoming chrome: the header meta
line (grouping label + events count), the event-pill provenance token,
the ✉ sr-only label, the world-band "historisch" suffix, and the strip
density caption — present in de/en/es with matching key sets, pinned by a
new parity test.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:39:58 +02:00
Marcel
b372b90ec9 docs(rtm): add #833 zeitstrahl-visual-fidelity REQ-001..016 rows
Trace the visual-fidelity requirements end to end before implementation,
matching the timeline-feature convention of landing RTM rows in the first
commit of the branch. Status: Planned; flipped to Done as each task lands.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:36:09 +02:00
Marcel
bc02d22270 refactor(timeline): drop EventForm's redundant type state
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m13s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
CI / Unit & Component Tests (push) Successful in 4m33s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m11s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m12s
The submitted type comes from EventTypeSelect's own hidden input; EventForm's
`type` $state was only read to seed `value=` and the onchange reassignment
had no downstream reader. Inline the seed expression and pass markDirty
directly, removing the second source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:13:17 +02:00
Marcel
23f6bc284d fix(timeline): validate RANGE end-date client-side with a field-level error
A RANGE event with a blank end date passed validateEventForm and reached
the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end
must not be before start" — wrong for a missing end date, and shown only as
a top-of-form alert. Validate it before the API call and surface a dedicated
event_editor_end_date_required message on the end-date field via a new
DatePrecisionField endDateError prop (defaults '', so the document form is
unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:11:20 +02:00
Marcel
9f2ae7bd2e fix(timeline): preserve date/precision/end across a fail(400)
preservedFormFields echoed back only title/type/description/pickers, and
EventForm seeded dateIso/precision/endDateIso solely from `event` (undefined
on /new). So a no-JS validation-error reload silently dropped the entire
When-section the curator had entered, while every other field survived.

Echo eventDate/precision/eventDateEnd in the fail payload and seed the date
controls from `form` ahead of `event`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:06:21 +02:00
Marcel
4d5fa7a26f fix(timeline): clear required-field errors when the field is corrected
titleError/dateError short-circuited on the server fail payload
(`form?.titleError ?? …`, `form?.dateError ?? ''`), so after a fail(400)
the red border and message stuck until the next submit even once the user
typed a valid value. Derive both from the current field value instead: the
server error still seeds the message, but a non-empty title/date clears it
immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:03:11 +02:00
Marcel
5f2cf5f2c2 fix(timeline): gate event delete behind the confirm dialog
The delete <form> combined onsubmit={confirmDelete} with use:enhance.
SvelteKit's enhance ignores event.defaultPrevented, so the DELETE fired on
the bare click — before the dialog was answered — and the post-DELETE
redirect ran regardless of the user's choice. Reading getConfirmService()
lazily inside the handler also threw (Svelte context is init-only), so the
dialog never appeared even with <ConfirmDialog> mounted.

Capture confirm at init and run the gate inside the enhance submit phase,
calling cancel() on "no". Clear dirty in the result callback so the
beforeNavigate guard no longer prompts "unsaved changes" on the post-delete
redirect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:00:45 +02:00
Marcel
b8c8fcb1fb fix(timeline): cancel blank-title event save instead of posting
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m35s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 6m14s
CI / fail2ban Regex (pull_request) Successful in 53s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
The EventForm onsubmit guard called e.preventDefault() on a blank title,
but use:enhance ignores defaultPrevented (forms.js only bails on cancel()),
so a blank-title Save still fired a network POST. In a component unit test
the resulting update() -> applyAction() dereferenced an undefined root
($set on undefined), surfacing as an unhandled rejection.

Move the guard into the enhance submit phase and call cancel() so the POST
is actually stopped; the server still owns the authoritative fail(400).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:34:59 +02:00
Marcel
6150fc7be5 fix(timeline): track form dirtiness via change callbacks, not an $effect
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m17s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m23s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 26s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 25s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
The round-2 dirty-guard used an $effect that both read and wrote its `dirtyArmed`
$state, so the self-write re-triggered the effect and `dirty` flipped true on
mount — the beforeNavigate confirm then fired on every navigation away from an
untouched form (caught by the round-3 clean-agent review + the Svelte autofixer,
which flags assigning state inside $effect).

Replace it with the component's existing idiomatic pattern: DatePrecisionField,
PersonMultiSelect, and DocumentMultiSelect each gain an optional `onchange`
callback fired on a real user edit, and EventForm passes `markDirty` to all
three. Now date/precision/end-date and picker add/remove mark the form dirty
exactly like title/type/description already did — no effect, no mount-timing
trap. The new props are optional, so the other consumers (WhoWhenSection, the
document forms) are unaffected. Svelte autofixer: clean.

Addresses PR #832 review (round-3 clean-agent concern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:55:34 +02:00
Marcel
0862d43ba3 fix(timeline): mark the event form dirty on date/precision/picker changes
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m53s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m46s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
The beforeNavigate unsaved-changes guard only fired for title/type/description
(their oninput/onchange hooks set `dirty`). Editing only the date, precision,
end-date, or the linked persons/documents left `dirty` false, so a curator could
navigate away and silently lose those edits — defeating the guard for the senior
author audience. Add an $effect that watches those values and marks dirty on any
change after the initial prop snapshot (first run only arms the watcher).

Addresses PR #832 review (round-2 clean-agent concern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:44:45 +02:00
Marcel
9cb856b376 test(e2e): update document-title-autosync precision selector to new id
DatePrecisionField derives the precision select's id from dateInputName, so the
document form's id moved from #metaDatePrecision to #documentDatePrecision (the
name attribute is unchanged). This maintained E2E still queried the old id and
would fail when run. Not CI-gated, but a real silent breakage.

Addresses PR #832 review (round-2 clean-agent out-of-diff finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:44:25 +02:00
Marcel
d330510777 test(document): update WhoWhenSection.test ids after DatePrecisionField extraction
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m58s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m32s
CI / fail2ban Regex (pull_request) Successful in 55s
CI / Semgrep Security Scan (pull_request) Successful in 32s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 21s
The DatePrecisionField extraction derives element ids from dateInputName, so the
document form's precision/end-date ids changed (metaDatePrecision →
documentDatePrecision, metaDateEnd → documentDateEnd, date-error →
documentDate-error, end-date-error → documentDate-end-error). The name attributes
are unchanged, so form submission is unaffected — but the pre-existing
WhoWhenSection.svelte.test.ts (a separate file from the .spec.ts) still queried
the old ids and was failing 5 assertions in CI's client project. It wasn't in
the PR diff, so the multi-persona review missed it. Re-point the selectors.

Addresses PR #832 review (round-1 clean-agent blocker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:39:38 +02:00
Marcel
719274ef88 docs(permissions): note requireWriteAll can replace the inline guard elsewhere
Some checks failed
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 21s
CI / Unit & Component Tests (pull_request) Failing after 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 4m49s
CI / fail2ban Regex (pull_request) Successful in 47s
Architect/Developer review suggestion: flag that other WRITE_ALL-gated author
loads (e.g. documents/[id]/edit) still inline the throw-403 guard and can adopt
requireWriteAll so it doesn't diverge. Comment-only.

Addresses PR #832 review (Architect suggestion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:33:38 +02:00
Marcel
d48a89ba5c test(timeline): assert submit-disabled transition and ≥44px chip targets
Tighten the review-flagged test gaps (no production change):
- submitting state: the old test only asserted the button was initially
  enabled (a tautology). Now stub a never-resolving fetch, click Speichern, and
  assert the button gains `disabled` — exercising the double-submit guard
  (Decision 8).
- the two redirect-throwing save tests now use `await expect(...).rejects` so a
  future missing redirect fails loudly instead of being swallowed by try/catch.
- the YEAR end-date-hide assertion uses getByLabelText('Enddatum') not-present,
  symmetric with the RANGE reveal, instead of a raw #eventDateEnd querySelector.
- PersonMultiSelect + DocumentMultiSelect: assert the chip remove button carries
  the min-h-[44px]/min-w-[44px] target the rtm cites for REQ-017.

Addresses PR #832 review (Tester + Requirements Engineer test-coverage concerns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:33:01 +02:00
Marcel
4dc5e3278f fix(timeline): wire the server date error to the date field's aria-invalid
REQ-011 asks for both required-field errors via per-field aria-invalid, but the
blank-date error was rendered as a standalone <p> after the date card while the
date input's aria-invalid only reflected the client-side malformed-date cue.

DatePrecisionField gains a `dateError` prop: a server error now marks the field
aria-invalid and renders inline under the input (sharing the same error id), and
EventForm drops its detached <p>. While here, migrate the field's two error
texts from hard-coded text-red-600 to the semantic `text-danger` token so they
keep ≥4.5:1 contrast in dark mode (the token remaps; #dc2626 was borderline) —
this also fixes the contrast for WhoWhenSection, the other consumer.

Component test asserts the date input gains aria-invalid on a server date error.

Addresses PR #832 review (Requirements Engineer REQ-011; UI/UX dark-mode contrast).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:30:40 +02:00
Marcel
c13baa4785 fix(timeline): rehydrate event-form pickers after a validation failure
Decision 6 / REQ-010 promised the pickers survive a fail(400) "including
pre-selected persons/documents", but EventForm only seeded them from
event/initialPersons — never from the fail payload — and the payload carried
only bare ids, which can't rebuild a chip (chips need displayName/title). On
the use:enhance path the in-memory selection survived; on a no-JS full reload
the chips were silently dropped.

Now the save action re-fetches the selected persons/documents by id
(lookupSelections, non-ok swallowed like the prefill path) and returns full
chip data; EventForm seeds the pickers from form.persons/documents ahead of
the seeded event. Extract preservedFormFields() to DRY the four fail payloads;
validateEventForm now returns the error pair and the route owns the fail().

Component test pins the rehydration; the server spec now asserts the fail
payload carries labelled chips, not just ids.

Addresses PR #832 review (Developer + Requirements Engineer concern, REQ-010).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:28:28 +02:00
Marcel
cd5649b96e fix(timeline): harden curator event precision field
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m51s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m35s
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 1m7s
SDD Gate / RTM Check (pull_request) Successful in 13s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
- Validate the submitted precision against the DatePrecision allow-list in
  parseEventForm (falls back to DAY) so an untrusted token can't flow into
  the request body — symmetric with the existing `type` narrowing.
- Parameterize the precision input name via DatePrecisionField's new
  precisionInputName prop; the timeline form now submits `precision` instead
  of the misleading document-domain `metaDatePrecision`. Document form keeps
  the default, so its behaviour is unchanged.
- Capture EventTypeSelect's onchange into EventForm's `type` state so it no
  longer goes stale (the submitted value was already correct via the hidden
  input; this keeps the local state in sync).

Addresses PR #832 review (#781).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:22:11 +02:00
Marcel
9f17c4538f refactor(timeline): extract requireWriteAll route guard
Both curator-event loaders repeated the same null-user + hasWriteAll block.
hasWriteAll already returns false for an anonymous user, so a single
requireWriteAll(locals) helper covers both REQ-002 (null user → 403) and
REQ-003 (no WRITE_ALL → 403) without the redundant pre-check.

Addresses PR #832 review (#781).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:21:37 +02:00
Marcel
068c2ef256 fix(document): render full date for precision-less document chips
A TimelineEvent's DocumentRef carries documentDate but no precision, so
formatDocumentOption hit formatDocumentDate's undefined-precision path and
surfaced the UNKNOWN label instead of the date. Default a missing precision
to DAY so the chip shows the full date; add formatDocumentOption unit specs.

Addresses PR #832 review (#781).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:21:07 +02:00
Marcel
94d7d8099f fix(types): make DocumentOption precision fields optional; narrow spec data access
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m54s
CI / fail2ban Regex (pull_request) Successful in 47s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 22s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
DocumentOption's metaDatePrecision/metaDateEnd are now optional so a
TimelineEvent DocumentRef (id/title/documentDate only) maps cleanly into a
picker chip — formatDocumentOption already degrades gracefully when precision is
absent. The server specs read fail()'s union data via a small failData() cast
that TS cannot narrow. svelte-check shows zero new errors in the #781 files.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:00:18 +02:00
Marcel
a50bdfa7f4 docs(timeline): document curator event routes (CLAUDE.md, C4, rtm)
Adds the events/new + events/[id]/edit children to both CLAUDE.md route tables
and the frontend C4 people-stories diagram (new zeitstrahlEvents component +
backend relation), and traces REQ-001..017 (feature timeline-curator-forms) in
.specify/rtm.md mirroring the sibling #776/#777/#778/#779 timeline rows.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:55:58 +02:00
Marcel
be26a2e1b3 test(e2e): thin curator event-editor journey (create, 403, 320px)
One critical create journey (fill form with precision RANGE → HTTP 200 on
/zeitstrahl; the card assertion depends on #7), one security counterpart
(logged-out → 403), and one 320px no-overflow guarantee. Intentionally thin —
ci.yml does not run test:e2e today, so regression coverage lives in the
component + server specs that DO run in CI. Written, not executed locally.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:53:30 +02:00
Marcel
5cfb4608f6 feat(timeline): add /zeitstrahl/events/[id]/edit curator edit + delete route
Load gates on hasWriteAll (null-user guard first, 403 error page) and seeds the
form from GET /api/timeline/events/{id}, failing closed with 404 on ANY non-ok
response so derived person-events (no UUID) and unknown ids never render a blank
create form. The save action PUTs with the optimistic-lock version (threaded via
a hidden input EventForm now emits), mapping 409 to the generic conflict message
without redirecting. The delete action DELETEs behind getConfirmService(),
returns fail(status) on a non-ok response (no redirect), and otherwise redirects
to the UUID-validated nav target. 8/8 server specs green; EventForm 6/6 green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:51:56 +02:00
Marcel
59d78150b3 feat(timeline): add /zeitstrahl/events/new curator create route
Server load gates on hasWriteAll with a null-user guard first (403 error page,
the persons/new idiom — not a redirect); prefills ?personId/?documentId via
Promise.all, swallowing 404/403 so unknown ids never leak. The save action
parses the form, surfaces title+date required errors simultaneously via
fail(400) preserving picker arrays, builds a TimelineEventRequest (eventDateEnd
explicit null off RANGE), POSTs, maps API/409 errors via getErrorMessage without
redirecting, and redirects to a UUID-validated nav target (CWE-601). Shared
parse/validate/build/nav helpers live in eventFormServer.ts for reuse by the
edit route. 11/11 server specs green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:49:05 +02:00
Marcel
15ff6db1d3 feat(timeline): add EventForm curator create/edit form
One component for both routes: /new renders it empty, /[id]/edit seeds it from a
TimelineEventView. Composes EventTypeSelect, the shared DatePrecisionField, a
plain-textarea description, PersonMultiSelect and DocumentMultiSelect (personIds
/documentIds hidden inputs). lg:grid-cols-[2fr_1fr] collapsing to one column
below lg, sticky save bar, beforeNavigate unsaved-changes guard, submitting flag
via use:enhance (disabled submit), and a delete form gated by getConfirmService()
read lazily so the component mounts cleanly in isolation. Title/description/chip
labels render via default {...} escaping (CWE-79). Seeded DocumentRefs degrade
gracefully to DocumentOption (no precision fields). Pickers gain an inputId prop
so <label for> associates the control; eslint boundaries now lets timeline import
person+document (mirrors the geschichte editor). 6/6 component specs green.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:45:39 +02:00
Marcel
54f9d8fdd5 feat(timeline): add EventTypeSelect segmented radio (PERSONAL/HISTORICAL)
grid-cols-2 segmented radio group modelled on PersonTypeSelector: role=radiogroup
with role=radio buttons, roving tabindex, radioGroupNav arrow-key support, and an
sr-only aria-live type-change announcement. Each option pairs a decorative
aria-hidden icon with a visible localized text label (icon is never the sole
differentiator), min-h-48px target. Emits a hidden input for form submission.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:40:00 +02:00
Marcel
423aedcd87 fix(a11y): 44px remove targets + empty states on person/document pickers
Both PersonMultiSelect and DocumentMultiSelect remove buttons were ~12px tap
targets (below the 44px WCAG minimum) — pad them to min-h/min-w 44px with a
focus-visible ring (SVG stays 12px). Add an optional emptyLabel slot inside the
chip container and a hiddenInputName prop on PersonMultiSelect (mirroring
DocumentMultiSelect) so EventForm can wire personIds without touching
WhoWhenSection. Document the intentional bare typeahead fetch in
documentTypeahead.ts (same-origin in prod, Vite-proxied in dev).

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:38:00 +02:00
Marcel
0ed7fb4c0e i18n(timeline): add event-editor keys (de/en/es)
Labels, section headings, type options (PERSONAL/HISTORICAL), picker empty
states, required-field errors, delete-confirm and unsaved-changes copy for the
curator event create/edit forms. No new ErrorCode introduced — the feature
reuses existing TIMELINE_EVENT_* + CONFLICT codes from #3.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:35:57 +02:00
Marcel
62fcc53f5c refactor(shared): extract DatePrecisionField primitive from WhoWhenSection
Pulls the date + precision + RANGE-end-date region into a generic primitive in
$lib/shared/primitives/ so both document/ (WhoWhenSection) and timeline/
(EventForm, #781) can consume it without a cross-domain import. Preserves the
aria-live="polite" outer wrapper, onMount one-time seeding, $bindable
precision/endDateIso, the PRECISIONS array, and forwards data-testid attributes
so the existing WhoWhenSection spec selectors survive. WhoWhenSection spec stays
green (7/7).

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:33:50 +02:00
Marcel
36f7bdad45 test(document): fence RANGE end-date reveal before DatePrecisionField extraction
Adds a RANGE-reveal regression test to WhoWhenSection's spec. The existing
spec covered only date pre-fill / hideDate / location, leaving the end-date
region without a red signal. This must stay green across the #781 extraction
of DatePrecisionField into $lib/shared/primitives/.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:30:56 +02:00
Marcel
696a86799d fix(a11y): add aria-modal to ConfirmDialog for older AT
NVDA+Chrome <2022 and VoiceOver iOS <16 need explicit aria-modal="true";
showModal() implicit modal semantics are not enough for older AT. One-line
patch benefits all dialog uses.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:29:46 +02:00
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
157 changed files with 9562 additions and 1183 deletions

View File

@@ -43,3 +43,133 @@
| 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 |
| REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#allows a curator with WRITE_ALL`, `[id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET` | Done |
| REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#throws 403 for an unauthenticated (null) user`, `[id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user` | Done |
| REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (hasWriteAll) | `new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
| REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (save), `lib/timeline/eventFormServer.ts#toEventRequest` | `new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success` | Done |
| REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success` | Done |
| REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete), `lib/timeline/EventForm.svelte` (getConfirmService) | `[id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success` | Done |
| REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete) | `[id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok` | Done |
| REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/EventForm.svelte` | `EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE`, `WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE` | Done |
| REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/eventFormServer.ts#parseEventForm` | `EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR`, `new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE` | Done |
| REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm`, `EventForm.svelte` | `EventForm.svelte.spec.ts#shows a required-field error when title is blank`, `new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title` | Done |
| REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm` | `new/page.server.spec.ts#surfaces both title and date errors when both blank` | Done |
| REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (load) | `[id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id)` | Done |
| REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect`, `new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409)` | Done |
| REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (load Promise.all), `EventForm.svelte` | `new/page.server.spec.ts#preselects a valid person and ignores an unknown document`, `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
| REQ-001 | `/zeitstrahl` wraps the timeline in a `.tl-canvas` surface (rounded, bg-canvas, padding; outer border dropped in review — page is already bg-canvas) | #833 | zeitstrahl-visual-fidelity | `frontend/src/routes/zeitstrahl/+page.svelte` | `routes/zeitstrahl/page.svelte.spec.ts#wraps the timeline in a padded canvas surface, without an outer border` | Done |
| REQ-002 | meta sub-line: range + letter count + event count (years + undated) + "Gruppierung: Datum"; range/line omitted when empty | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/timelineMeta.ts`, `frontend/src/routes/zeitstrahl/+page.svelte` | `timelineMeta.spec.ts` (4 cases), `routes/zeitstrahl/page.svelte.spec.ts#renders the meta sub-line`, `#omits the range segment`, `#omits the entire sub-line` | Done |
| REQ-003 | year badge centered on axis ≥1024px, left spine <1024px; sticky top:4rem preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#centers the year badge on the axis at desktop`, `#left-aligns the year badge at phone width`, `#keeps the sticky year heading at top:4rem` | Done |
| REQ-004 | year badge node marker on the spine, never overlapping the badge text (desktop + phone) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders a year-badge node marker that clears the badge text on phone` | Done |
| REQ-005 | per-letter connector dot (white fill, mint ring) on the spine; phone column indented clear of card | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders one connector dot per letter row, each clearing its card on phone` | Done |
| REQ-006 | axis gradient 3-stop mint→navy→slate via `--palette-mint`/`--palette-navy`/`--c-tag-slate` | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#paints the axis with a three-stop gradient` (+ REQ-013 grep) | Done |
| REQ-007 | EventPill subtitle `{date} · {provenance}` keyed off `entry.derived` (abgeleitet/kuratiert) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#appends the "abgeleitet" provenance`, `#appends the "kuratiert" provenance`, `#never shows persönlich/SEASON` | Done |
| REQ-008 | LetterCard title prefixed with `aria-hidden` ✉ + sr-only "Brief"; href intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#prefixes a present title with an aria-hidden ✉`, `#renders an HTML-bearing title verbatim` | Done |
| REQ-009 | WorldBand inline "· historisch" descriptor (non-RANGE & RANGE); RANGE span pill + aria-label intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#appends the inline "· historisch"`, `#follows the RANGE span pill with inline "· historisch"` | Done |
| REQ-010 | YearLetterStrip count ✉ + sr-only label + "Monats-Dichte" caption; expand toggle preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#prefixes the count with an aria-hidden ✉`, `#keeps the expand toggle and its label` | Done |
| REQ-011 | YearLetterStrip exactly two endpoint month-axis labels (Jan/Dez {year}) ≥10px via formatTickLabel | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#renders exactly two endpoint month-axis labels` | Done |
| REQ-012 | undated "Ohne Datum · {count}" in a dashed frame; empty → absent; kind/type dispatch preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#frames the undated section with a dashed border and shows the count`, `#omits the "Ohne Datum" section when empty` | Done |
| REQ-013 | all new styles use semantic tokens; corrected hex grep returns zero hits | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | grep gate (REQ-013 form) → zero | Done |
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |
| REQ-001 | store relationship from/to as nullable LocalDate + NOT-NULL DatePrecision (default UNKNOWN) | #837 | relationship-edit-dates | `person/relationship/PersonRelationship.java`, `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#yearColumnsDropped_andNamedCheckConstraintsExist`, `RelationshipServiceTest#addRelationship_persists_with_storage_truth` | Done |
| REQ-002 | V78 backfills non-null years as `{year}-01-01`/YEAR, nulls → null/UNKNOWN, rows preserved | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#backfill_fromYearAndToYear_becomeYearPrecisionDates`, `#backfill_bothNull_leavesDatesNullAndPrecisionsUnknown`, `#backfill_preservesRowCount` | Done |
| REQ-003 | named DB CHECKs: coherence both ends + fromDate ≤ toDate | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#orderCheckConstraint_rejectsToDateBeforeFromDate`, `#coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision` | Done |
| REQ-004 | PUT updates the relationship → 200 RelationshipDTO | #837 | relationship-edit-dates | `person/relationship/RelationshipController#updateRelationship`, `RelationshipService#updateRelationship` | `RelationshipControllerTest#updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user`, `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `page.server.spec.ts#updateRelationship PUTs to the relId path with the new body` | Done |
| REQ-005 | create + update rejected with 403 without WRITE_ALL | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (`@RequirePermission`) | `RelationshipControllerTest#updateRelationship_returns403_for_READ_ALL_only_user`, `#addRelationship_returns403_for_user_with_READ_ALL_only` | Done |
| REQ-006 | relId not existing / not owned by person → 404 RELATIONSHIP_NOT_FOUND | #837 | relationship-edit-dates | `person/relationship/RelationshipService#loadOwnedRelationship` | `RelationshipServiceTest#updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person`, `RelationshipServiceIntegrationTest#updateRelationship_throws_404_when_rel_belongs_to_different_person` | Done |
| REQ-007 | update with relatedPersonId == {id} → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_VALIDATION_ERROR_on_self_relation` | Done |
| REQ-008 | resulting (person, relatedPerson, type) duplicate → 409 DUPLICATE_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_DUPLICATE_when_db_constraint_violated` | Done |
| REQ-009 | update to PARENT_OF with reverse PARENT_OF present → 409 CIRCULAR_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists` | Done |
| REQ-010 | toDate before fromDate → 400 INVALID_RELATIONSHIP_DATES | #837 | relationship-edit-dates | `person/relationship/RelationshipService#validateRelationshipDates`, `exception/ErrorCode`, `frontend/src/lib/shared/errors.ts` | `RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate`, `#updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate` | Done |
| REQ-011 | date+UNKNOWN precision, or precision without date → 400 INVALID_DATE_PRECISION | #837 | relationship-edit-dates | `person/relationship/RelationshipService#requireDatePrecisionCoherence` | `RelationshipServiceTest#addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown`, `#addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date` | Done |
| REQ-012 | invalid enum / missing relatedPersonId·relationType / notes > 2000 → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipUpsertRequest` (Bean Validation), `RelationshipController` | `RelationshipControllerTest#updateRelationship_returns400_when_relationType_is_unknown_value`, `#addRelationship_returns400_when_relationType_is_unknown_value` | Done |
| REQ-013 | updating into a family type flags both endpoints (additive) | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_marks_both_endpoints_family_when_updated_to_family_type` | Done |
| REQ-014 | persist + display notes on create, update, read and edit views | #837 | relationship-edit-dates | `person/relationship/RelationshipService`, `frontend/.../AddRelationshipForm.svelte`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `AddRelationshipForm.svelte.spec.ts#round-trips the notes into the textarea`, `PersonRelationshipsCard.svelte.test.ts#shows the notes line` | Done |
| REQ-015 | detail view shows the date range at its precision; no dates → no date line | #837 | relationship-edit-dates | `frontend/src/lib/person/relationshipDates.ts`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `relationshipDates.spec.ts`, `PersonRelationshipsCard.svelte.test.ts#renders the date range at its stored precision`, `#renders no date line when the relationship has no dates` | Done |
| REQ-016 | edit affordance opens a form pre-filled with type/person/dates+precision/notes; precision DAY/MONTH/YEAR | #837 | relationship-edit-dates | `frontend/.../AddRelationshipForm.svelte`, `RelationshipDateField.svelte`, `RelationshipChip.svelte` | `AddRelationshipForm.svelte.spec.ts#pre-fills the from-date as dd.mm.yyyy`, `#offers only DAY/MONTH/YEAR in each precision select`, `RelationshipChip.svelte.spec.ts#shows an Edit affordance with an accessible name when canWrite and onEdit` | Done |
| REQ-017 | derived Heirat sources SPOUSE_OF.fromDate + fromDatePrecision | #837 | relationship-edit-dates | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDate` | Done |
| REQ-018 | unauthenticated PUT → 401, no row modified | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (SecurityConfig) | `RelationshipControllerTest#updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service` | Done |
| REQ-019 | while a create/update request is in flight, submit is disabled + shows a progress indicator | #837 | relationship-edit-dates | `frontend/src/lib/person/relationship/AddRelationshipForm.svelte` | `AddRelationshipForm.svelte.spec.ts#disables the submit and shows a progress spinner while a submit is in flight` | Done |
| REQ-001 | TimelineEntryDTO carries rootTagId/rootTagName/rootTagColor for LETTER entries, assembled in-transaction (id+name+token only) | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `timeline/TimelineService#mapDocument` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag` | Done |
| REQ-002 | the three root-tag fields are nullable and not `@Schema(requiredMode = REQUIRED)` | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `frontend/src/lib/generated/api.ts` (optional) | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields` (+ regenerated `api.ts` shows `rootTag*?`) | Done |
| REQ-003 | primary tag = root ancestor of the alphabetically-first assigned tag, resolved via TagService | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `TagServiceTest#resolveRootTags_walksChildToRoot_withRootColor`, `TagServiceIntegrationTest#resolveRootTags_walksPersistedChainToRoot_withRootColor` | Done |
| REQ-004 | roots resolved in a single batched/memoized pass (≤ M findAncestorIds, no per-letter N+1); color from the root token | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#resolveLetterRootTags` | `TagServiceTest#resolveRootTags_memoizesPerDistinctTag_noNPlusOne`, `TimelineServiceTest#root_tags_resolved_in_a_single_batched_pass` | Done |
| REQ-005 | a letter with no tags → all three fields null; LetterCard renders no chip | #835 | zeitstrahl-tag-chips | `timeline/TimelineService#mapDocument`, `frontend/src/lib/timeline/LetterCard.svelte` | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields`, `LetterCard.svelte.spec.ts#renders no chip when the letter has no root tag` | Done |
| REQ-006 | a letter with multiple tags → exactly one primary root (deterministic) | #835 | zeitstrahl-tag-chips | `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `LetterCard.svelte.spec.ts#renders one root-tag chip beneath the meta line` | Done |
| REQ-007 | a colorless root → rootTagColor null; frontend renders a neutral chip, no `var(--c-tag-)` | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `frontend/src/lib/timeline/TagChip.svelte` | `TagServiceTest#resolveRootTags_returnsNullColor_whenRootHasNoColor`, `TimelineServiceTest#letter_primary_root_without_color_yields_null_color`, `TagChip.svelte.spec.ts#renders a neutral chip with no --c-tag- binding when color is null` | Done |
| REQ-008 | LetterCard with a rootTagName renders one §3 chip beneath the meta line | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte`, `LetterCard.svelte` | `TagChip.svelte.spec.ts#renders the tag name`, `LetterCard.svelte.spec.ts#renders one root-tag chip beneath the meta line` | Done |
| REQ-008a | a long name truncates with ellipsis, no horizontal overflow at 320px; full name in the chip title | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte`, `LetterCard.svelte` | `LetterCard.svelte.spec.ts#keeps a long tag name from overflowing the card at 320px`, `TagChip.svelte.spec.ts#exposes the full name as the chip title` | Done |
| REQ-009 | chip color applied via `var(--c-tag-{token})`, no raw hex | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#applies the color via var(--c-tag-{token}), never raw hex` | Done |
| REQ-010 | rootTagName rendered via `{...}` escaping; no `{@html}` in lib/timeline | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `grep -rn '@html' frontend/src/lib/timeline/` → zero | Done |
| REQ-011 | colored square aria-hidden; sr-only theme label prefix so color is never the only cue | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#prefixes the name with an sr-only theme label and a decorative square` | Done |
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |

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`
@@ -169,7 +170,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop). **LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
### Security / Permissions ### Security / Permissions
@@ -206,6 +207,8 @@ 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)
│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm
├── 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
@@ -277,7 +280,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop). **LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
--- ---

View File

@@ -0,0 +1,42 @@
package org.raddatz.familienarchiv.document;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import java.time.LocalDate;
/**
* Cross-field validation and normalization shared by every domain that stores a
* {@link LocalDate} + {@link DatePrecision} pair — a person's life dates (ADR-039 / V76)
* and a relationship's from/to dates (ADR-044 / V78). Kept out of {@link DatePrecision}
* itself because that enum is a frozen contract mirror of the import normalizer (ADR-025)
* and must carry no behaviour.
*/
public final class DatePrecisionValidation {
private DatePrecisionValidation() {}
/**
* Enforces the date ⇔ precision coherence the V76/V78 CHECK constraints also enforce:
* a date requires a non-{@code UNKNOWN} precision, and a non-{@code UNKNOWN} precision
* requires a date. Validated in-service so the caller gets a structured 400 instead of
* the database constraint's raw 500.
*
* @param side human-readable field label woven into the error message ("birth", "from", …)
*/
public static void requireCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
}
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
}
}
/** A null precision means "no precision recorded" → {@link DatePrecision#UNKNOWN}. */
public static DatePrecision normalize(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
}

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

@@ -122,6 +122,8 @@ public enum ErrorCode {
CIRCULAR_RELATIONSHIP, CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */ /** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP, DUPLICATE_RELATIONSHIP,
/** A relationship's toDate is before its fromDate. 400 */
INVALID_RELATIONSHIP_DATES,
// --- Geschichten (Stories) --- // --- Geschichten (Stories) ---
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */ /** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */

View File

@@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.PersonUpsertCommand;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService; import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.File; import java.io.File;
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) { private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
try { try {
relationshipService.addRelationship(person, relationshipService.addRelationship(person,
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null)); new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null));
return true; return true;
} catch (DomainException e) { } catch (DomainException e) {
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP

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

@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO; import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO; import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision; import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
import org.raddatz.familienarchiv.exception.DomainException; 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;
@@ -210,6 +211,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);
@@ -444,41 +449,28 @@ public class PersonService {
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()) .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthDate(dto.getBirthDate()) .birthDate(dto.getBirthDate())
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())) .birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate()) .deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())) .deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
.generation(dto.getGeneration()) .generation(dto.getGeneration())
.build(); .build();
return personRepository.save(person); return personRepository.save(person);
} }
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the // Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
// user gets a structured ErrorCode instead of a raw constraint-violation 500. // user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence
// is shared with the relationship domain (DatePrecisionValidation); only the order check
// (and its BIRTH_AFTER_DEATH code) is life-date specific.
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision, private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) { LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth"); DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death"); DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) { if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH, throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"Birth date " + birthDate + " is after death date " + deathDate); "Birth date " + birthDate + " is after death date " + deathDate);
} }
} }
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
}
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
}
}
private static DatePrecision normalizePrecision(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
@Transactional @Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) { public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) { if (dto.getPersonType() == PersonType.SKIP) {
@@ -495,9 +487,9 @@ public class PersonService {
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()); person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()); person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthDate(dto.getBirthDate()); person.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision())); person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate()); person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision())); person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
// Form path: a human can clear generation back to null. Unlike the importer // Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim. // which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration()); person.setGeneration(dto.getGeneration());

View File

@@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@@ -39,11 +41,25 @@ public class PersonRelationship {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private RelationType relationType; private RelationType relationType;
@Column(name = "from_year") // Start/end of the relationship (wedding, employment start, …). The date column
private Integer fromYear; // is nullable, the precision column is NOT NULL with UNKNOWN meaning "no date" —
// the V78 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN) and
// from_date <= to_date. Mirrors Person.{birth,death}Date (ADR-039 / ADR-044).
private LocalDate fromDate;
@Column(name = "to_year") @Enumerated(EnumType.STRING)
private Integer toYear; @Column(name = "from_date_precision", nullable = false, length = 16)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN;
private LocalDate toDate;
@Enumerated(EnumType.STRING)
@Column(name = "to_date_precision", nullable = false, length = 16)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private DatePrecision toDatePrecision = DatePrecision.UNKNOWN;
@Column(length = 2000) @Column(length = 2000)
private String notes; private String notes;

View File

@@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO; import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
@@ -63,11 +63,20 @@ public class RelationshipController {
@RequirePermission(Permission.WRITE_ALL) @RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<RelationshipDTO> addRelationship( public ResponseEntity<RelationshipDTO> addRelationship(
@PathVariable UUID id, @PathVariable UUID id,
@Valid @RequestBody CreateRelationshipRequest dto) { @Valid @RequestBody RelationshipUpsertRequest dto) {
return ResponseEntity.status(HttpStatus.CREATED) return ResponseEntity.status(HttpStatus.CREATED)
.body(relationshipService.addRelationship(id, dto)); .body(relationshipService.addRelationship(id, dto));
} }
@PutMapping("/api/persons/{id}/relationships/{relId}")
@RequirePermission(Permission.WRITE_ALL)
public RelationshipDTO updateRelationship(
@PathVariable UUID id,
@PathVariable UUID relId,
@Valid @RequestBody RelationshipUpsertRequest dto) {
return relationshipService.updateRelationship(id, relId, dto);
}
@DeleteMapping("/api/persons/{id}/relationships/{relId}") @DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL) @RequirePermission(Permission.WRITE_ALL)

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.person.relationship; package org.raddatz.familienarchiv.person.relationship;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
import org.raddatz.familienarchiv.exception.DomainException; 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.relationship.dto.CreateRelationshipRequest; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
@@ -86,66 +88,139 @@ 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, RelationshipUpsertRequest dto) {
if (personId.equals(dto.relatedPersonId())) { requireNotSelf(personId, dto.relatedPersonId());
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
Person person = personService.getById(personId); Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId()); Person relatedPerson = personService.getById(dto.relatedPersonId());
validateYears(dto.fromYear(), dto.toYear()); validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
if (dto.relationType() == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
}
PersonRelationship rel = PersonRelationship.builder() PersonRelationship rel = PersonRelationship.builder()
.person(person) .person(person)
.relatedPerson(relatedPerson) .relatedPerson(relatedPerson)
.relationType(dto.relationType()) .relationType(dto.relationType())
.fromYear(dto.fromYear()) .fromDate(dto.fromDate())
.toYear(dto.toYear()) .fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
.toDate(dto.toDate())
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
.notes(blankToNull(dto.notes())) .notes(blankToNull(dto.notes()))
.build(); .build();
PersonRelationship saved; PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
try { flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
// saveAndFlush so the unique_rel constraint violates synchronously and is
// caught here, not at commit time outside the @Transactional boundary.
saved = relationshipRepository.saveAndFlush(rel);
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
}
// Family-graph edges imply both endpoints are family members. Idempotent: the
// setter is a no-op when the person is already flagged, so re-imports stay clean.
if (FAMILY_RELATION_TYPES.contains(dto.relationType())) {
personService.setFamilyMember(person.getId(), true);
personService.setFamilyMember(relatedPerson.getId(), true);
}
return toDTO(saved); return toDTO(saved);
} }
@Transactional
public RelationshipDTO updateRelationship(UUID personId, UUID relId, RelationshipUpsertRequest dto) {
PersonRelationship rel = loadOwnedRelationship(personId, relId);
// The other party from {personId}'s viewpoint cannot be {personId} itself.
requireNotSelf(personId, dto.relatedPersonId());
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
// Preserve the directed orientation: {personId} keeps whichever role (subject or
// object) it already holds on the row, and the edited "related person" takes the
// other role. So a PARENT_OF edge stays parent→child whether the curator edits it
// from the parent's page or the child's.
boolean viewpointIsSubject = personId.equals(rel.getPerson().getId());
Person viewpoint = viewpointIsSubject ? rel.getPerson() : rel.getRelatedPerson();
Person other = personService.getById(dto.relatedPersonId());
Person newSubject = viewpointIsSubject ? viewpoint : other;
Person newObject = viewpointIsSubject ? other : viewpoint;
requireNoReverseParent(newSubject.getId(), newObject.getId(), dto.relationType());
rel.setPerson(newSubject);
rel.setRelatedPerson(newObject);
rel.setRelationType(dto.relationType());
rel.setFromDate(dto.fromDate());
rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
rel.setToDate(dto.toDate());
rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
rel.setNotes(blankToNull(dto.notes()));
PersonRelationship saved = persistOrConflict(rel, newSubject.getId(), newObject.getId(), dto.relationType());
flagFamilyMembership(dto.relationType(), newSubject.getId(), newObject.getId());
return toDTO(saved);
}
// --- shared create/update invariants ---------------------------------------------
// A person cannot be related to themselves, from either viewpoint.
private static void requireNotSelf(UUID viewpointId, UUID relatedPersonId) {
if (viewpointId.equals(relatedPersonId)) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
}
// A PARENT_OF edge must not already have its mirror (child PARENT_OF parent) stored —
// that would be a cycle. No-op for every other relation type.
private void requireNoReverseParent(UUID subjectId, UUID objectId, RelationType type) {
if (type == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
objectId, subjectId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + subjectId + " and " + objectId);
}
}
// saveAndFlush so the unique_rel constraint violates synchronously and is caught here,
// inside the @Transactional boundary, not at commit time as a raw 500.
private PersonRelationship persistOrConflict(PersonRelationship rel, UUID subjectId, UUID objectId, RelationType type) {
try {
return relationshipRepository.saveAndFlush(rel);
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")");
}
}
// Family-graph edges imply both endpoints are family members. Idempotent (the setter is
// a no-op when already flagged, so re-imports stay clean) and additive — an edit never
// auto-unflags.
private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) {
if (FAMILY_RELATION_TYPES.contains(type)) {
personService.setFamilyMember(subjectId, true);
personService.setFamilyMember(objectId, true);
}
}
@Transactional @Transactional
public void deleteRelationship(UUID personId, UUID relId) { public void deleteRelationship(UUID personId, UUID relId) {
PersonRelationship rel = loadOwnedRelationship(personId, relId);
relationshipRepository.delete(rel);
}
// Loads the row and verifies {personId} is one of its endpoints. A mismatch is 404
// (not 403): an anti-enumeration choice so a curator cannot probe relationship ids
// belonging to people they cannot see. Shared by update + delete for consistency.
private PersonRelationship loadOwnedRelationship(UUID personId, UUID relId) {
PersonRelationship rel = relationshipRepository.findById(relId) PersonRelationship rel = relationshipRepository.findById(relId)
.orElseThrow(() -> DomainException.notFound( .orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId)); ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId(); UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId(); UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) { if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
throw DomainException.forbidden( throw DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND,
"Relationship " + relId + " does not belong to person " + personId); "Relationship " + relId + " does not belong to person " + personId);
} }
relationshipRepository.delete(rel); return rel;
} }
@Transactional @Transactional
@@ -164,10 +239,17 @@ public class RelationshipService {
return date != null ? date.getYear() : null; return date != null ? date.getYear() : null;
} }
private static void validateYears(Integer fromYear, Integer toYear) { // Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
if (fromYear != null && toYear != null && toYear < fromYear) { // user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
throw DomainException.badRequest( // Coherence is shared with the person domain (DatePrecisionValidation); only the order
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear"); // check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
LocalDate toDate, DatePrecision toPrecision) {
DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
"toDate " + toDate + " is before fromDate " + fromDate);
} }
} }
@@ -185,8 +267,10 @@ public class RelationshipService {
yearOf(rp.getBirthDate()), yearOf(rp.getBirthDate()),
yearOf(rp.getDeathDate()), yearOf(rp.getDeathDate()),
r.getRelationType(), r.getRelationType(),
r.getFromYear(), r.getFromDate(),
r.getToYear(), r.getFromDatePrecision(),
r.getToDate(),
r.getToDatePrecision(),
r.getNotes()); r.getNotes());
} }
} }

View File

@@ -1,15 +0,0 @@
package org.raddatz.familienarchiv.person.relationship.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import java.util.UUID;
public record CreateRelationshipRequest(
@NotNull UUID relatedPersonId,
@NotNull RelationType relationType,
Integer fromYear,
Integer toYear,
@Size(max = 2000) String notes
) {}

View File

@@ -1,8 +1,10 @@
package org.raddatz.familienarchiv.person.relationship.dto; package org.raddatz.familienarchiv.person.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -26,7 +28,9 @@ public record RelationshipDTO(
Integer relatedPersonBirthYear, Integer relatedPersonBirthYear,
Integer relatedPersonDeathYear, Integer relatedPersonDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
Integer fromYear, LocalDate fromDate,
Integer toYear, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision,
LocalDate toDate,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision,
String notes String notes
) {} ) {}

View File

@@ -0,0 +1,26 @@
package org.raddatz.familienarchiv.person.relationship.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import java.time.LocalDate;
import java.util.UUID;
/**
* Request body for both creating and updating a relationship — the fields are
* identical, so one record serves {@code POST} and {@code PUT} (DRY). A null
* {@code *DatePrecision} is normalized to {@code UNKNOWN} by the service; the
* service then enforces coherence (date ⇔ non-UNKNOWN precision) and order
* (fromDate ≤ toDate).
*/
public record RelationshipUpsertRequest(
@NotNull UUID relatedPersonId,
@NotNull RelationType relationType,
LocalDate fromDate,
DatePrecision fromDatePrecision,
LocalDate toDate,
DatePrecision toDatePrecision,
@Size(max = 2000) String notes
) {}

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.tag;
import java.util.UUID;
/**
* The root-ancestor view of a tag: its id, display name, and color token.
* Colors are stored only on root tags, so {@code color} is the authoritative token
* (one of {@link TagService#ALLOWED_TAG_COLORS}) or {@code null} when the root has none.
* Returned by {@link TagService#resolveRootTags} for read surfaces (the timeline chip,
* later the Thema buckets) that need a tag's theme without the entity graph.
*/
public record RootTag(UUID id, String name, String color) {
}

View File

@@ -175,6 +175,59 @@ public class TagService {
}); });
} }
/**
* Resolves each given tag to its root ancestor, returning a {@link RootTag} (id, name, color
* token) keyed by the input tag's id. A root tag maps to itself; a child is walked to the
* ancestor with no parent via {@link TagRepository#findAncestorIds} (one CTE per distinct
* non-root tag, memoized) plus a single batched {@code findAllById}, so a timeline of many
* letters sharing few tags costs O(distinct tags) queries, never O(letters). The color comes
* from the resolved root's stored token (null when the root has none). Safe on detached tags.
*/
public Map<UUID, RootTag> resolveRootTags(Collection<Tag> tags) {
if (tags == null || tags.isEmpty()) return Map.of();
Map<UUID, Tag> distinct = new LinkedHashMap<>();
for (Tag tag : tags) {
if (tag != null && tag.getId() != null) distinct.putIfAbsent(tag.getId(), tag);
}
Map<UUID, List<UUID>> ancestorIdsByTagId = new HashMap<>();
Set<UUID> idsToLoad = new HashSet<>();
for (Tag tag : distinct.values()) {
if (tag.getParentId() == null) continue;
List<UUID> ancestorIds = tagRepository.findAncestorIds(tag.getId());
ancestorIdsByTagId.put(tag.getId(), ancestorIds);
idsToLoad.addAll(ancestorIds);
}
Map<UUID, Tag> ancestorsById = idsToLoad.isEmpty() ? Map.of()
: tagRepository.findAllById(idsToLoad).stream()
.collect(Collectors.toMap(Tag::getId, t -> t));
Map<UUID, RootTag> result = new HashMap<>();
for (Tag tag : distinct.values()) {
Tag root = resolveRoot(tag, ancestorIdsByTagId.get(tag.getId()), ancestorsById);
result.put(tag.getId(), new RootTag(root.getId(), root.getName(), root.getColor()));
}
return result;
}
private Tag resolveRoot(Tag tag, List<UUID> ancestorIds, Map<UUID, Tag> ancestorsById) {
if (tag.getParentId() == null) return tag;
if (ancestorIds != null) {
for (UUID ancestorId : ancestorIds) {
Tag ancestor = ancestorsById.get(ancestorId);
if (ancestor != null && ancestor.getParentId() == null) return ancestor;
}
}
// No null-parent ancestor surfaced — the parent is orphaned or the chain is deeper than the
// findAncestorIds CTE's depth guard. Fall back to the tag as its own root, but surface it:
// a silently mislabeled root would otherwise be invisible. UUIDs only (no tag names logged).
log.warn("Tag {} has parent {} but no root surfaced from its ancestry; "
+ "treating it as its own root.", tag.getId(), tag.getParentId());
return tag;
}
/** /**
* For each tag name, returns the set of that tag's ID plus all descendant IDs. * For each tag name, returns the set of that tag's ID plus all descendant IDs.
* Used by DocumentService to expand selected filter tags before applying AND/OR logic. * Used by DocumentService to expand selected filter tags before applying AND/OR logic.

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,52 @@
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><b>Root-tag fields ({@code rootTagId}/{@code rootTagName}/{@code rootTagColor}):</b> the
* letter's primary root tag — the root ancestor of its alphabetically-first assigned tag (#835).
* All three are {@code null} for non-{@link Kind#LETTER} entries and for letters with no tags;
* {@code rootTagColor} is additionally {@code null} when the resolved root carries no color token.
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* types stay optional.
*
* <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,
UUID rootTagId,
String rootTagName,
String rootTagColor
) {
}

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,84 @@ 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,
null, null, null))
.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,
null, null, null))
.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.
// The marriage date is the relationship's from_date at its stored precision
// (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year.
LocalDate eventDate = r.getFromDate();
DatePrecision precision = r.getFromDatePrecision();
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,
null, null, null));
}
}
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,318 @@
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.raddatz.familienarchiv.tag.RootTag;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
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;
private final TagService tagService;
/**
* 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> letters = new ArrayList<>();
for (Document doc : fetchDocuments(filter.personId())) {
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
letters.add(doc);
}
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId));
}
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,
null,
null,
null
);
}
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
RootTag root = rootByDocId.get(doc.getId());
return new TimelineEntryDTO(
Kind.LETTER,
doc.getMetaDatePrecision(),
false,
resolveSenderName(doc),
resolveReceiverName(doc),
doc.getDocumentDate(),
null,
doc.getTitle(),
null,
null,
doc.getId(),
List.of(),
null,
root == null ? null : root.id(),
root == null ? null : root.name(),
root == null ? null : root.color()
);
}
/**
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
* so the {@code min()} scan over a letter's tag set runs exactly once here (not again at map
* time), and {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag.
*/
private Map<UUID, RootTag> resolveLetterRootTags(List<Document> letters) {
Map<UUID, Tag> primaryByDocId = new LinkedHashMap<>();
for (Document doc : letters) {
Tag primary = primaryTag(doc);
if (primary != null) primaryByDocId.put(doc.getId(), primary);
}
if (primaryByDocId.isEmpty()) return Map.of();
Map<UUID, RootTag> rootByTagId =
tagService.resolveRootTags(new ArrayList<>(primaryByDocId.values()));
Map<UUID, RootTag> rootByDocId = new HashMap<>();
primaryByDocId.forEach((docId, primary) -> {
RootTag root = rootByTagId.get(primary.getId());
if (root != null) rootByDocId.put(docId, root);
});
return rootByDocId;
}
/** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */
private static Tag primaryTag(Document doc) {
Set<Tag> tags = doc.getTags();
if (tags == null || tags.isEmpty()) return null;
return tags.stream()
.filter(t -> t.getName() != null)
.min(Comparator.comparing(Tag::getName))
.orElse(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

@@ -0,0 +1,43 @@
-- V78: person_relationships.from_year/to_year (integer) → from_date/to_date (date)
-- plus NOT NULL precision columns, mirroring persons.{birth,death}_date (V76 / ADR-039).
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-044).
-- One-way migration: rollback is a targeted pg_restore -t person_relationships from
-- the pre-deploy backup (see docs/DEPLOYMENT.md). The column drop is NOT
-- rolling-deploy-safe — stop the old JAR before running this migration.
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
-- before any DDL runs. Single-writer family archive, so no race window matters.
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year)
THEN RAISE EXCEPTION 'V78 aborted: % relationships have from_year > to_year — fix data before migrating',
(SELECT COUNT(*) FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year);
END IF;
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year = 0 OR to_year = 0)
THEN RAISE EXCEPTION 'V78 aborted: person_relationships table contains from_year=0 or to_year=0 rows — clean data before migrating';
END IF;
END $$;
ALTER TABLE person_relationships ADD COLUMN from_date date;
ALTER TABLE person_relationships ADD COLUMN from_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
ALTER TABLE person_relationships ADD COLUMN to_date date;
ALTER TABLE person_relationships ADD COLUMN to_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
UPDATE person_relationships SET from_date = make_date(from_year, 1, 1), from_date_precision = 'YEAR'
WHERE from_year IS NOT NULL;
UPDATE person_relationships SET to_date = make_date(to_year, 1, 1), to_date_precision = 'YEAR'
WHERE to_year IS NOT NULL;
-- Named constraints: readable Postgres error messages when violated.
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_coherence
CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'));
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_coherence
CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'));
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_date_order
CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date);
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_precision_values
CHECK (from_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_precision_values
CHECK (to_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE person_relationships DROP COLUMN from_year;
ALTER TABLE person_relationships DROP COLUMN to_year;

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

@@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService; import org.raddatz.familienarchiv.person.relationship.RelationshipService;
@@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest {
RelationshipDTO edge = new RelationshipDTO( RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), parentId, childId, UUID.randomUUID(), parentId, childId,
"Parent", null, null, "Child", null, null, "Parent", null, null, "Child", null, null,
RelationType.PARENT_OF, null, null, null); RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
when(relationshipService.getFamilyNetwork()) when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge))); .thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of())); when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));

View File

@@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.PersonUpsertCommand; import org.raddatz.familienarchiv.person.PersonUpsertCommand;
import org.raddatz.familienarchiv.person.relationship.RelationType; import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService; import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
new PersonTreeImporter(personService, relationshipService) new PersonTreeImporter(personService, relationshipService)
.load(json.toFile()); .load(json.toFile());
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class); ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
verify(relationshipService).addRelationship(eq(idA), captor.capture()); verify(relationshipService).addRelationship(eq(idA), captor.capture());
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB); assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF); assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);

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

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.person.relationship; package org.raddatz.familienarchiv.person.relationship;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO; import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
@@ -25,6 +26,8 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -98,7 +101,7 @@ class RelationshipControllerTest {
UUID.randomUUID(), PERSON_ID, OTHER_ID, UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980, "Alice Müller", 1900, 1980,
"Bob Müller", 1930, null, "Bob Müller", 1930, null,
RelationType.PARENT_OF, null, null, null); RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
when(relationshipService.getFamilyNetwork()) when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(node), List.of(edge))); .thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
@@ -139,7 +142,7 @@ class RelationshipControllerTest {
UUID.randomUUID(), PERSON_ID, OTHER_ID, UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", null, null, "Alice Müller", null, null,
"Bob Müller", null, null, "Bob Müller", null, null,
RelationType.PARENT_OF, null, null, null); RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created); when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
@@ -158,4 +161,51 @@ class RelationshipControllerTest {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())) mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
// ─── PUT /api/persons/{id}/relationships/{relId} ──────────────────────────
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
UUID relId = UUID.randomUUID();
RelationshipDTO updated = new RelationshipDTO(
relId, PERSON_ID, OTHER_ID,
"Alice Müller", null, null,
"Bob Müller", null, null,
RelationType.SPOUSE_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
when(relationshipService.updateRelationship(any(), any(), any())).thenReturn(updated);
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.relationType").value("SPOUSE_OF"));
}
@Test
void updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service() throws Exception {
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
.andExpect(status().isUnauthorized());
verify(relationshipService, never()).updateRelationship(any(), any(), any());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void updateRelationship_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void updateRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest());
}
} }

View File

@@ -0,0 +1,306 @@
package org.raddatz.familienarchiv.person.relationship;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Verifies V78: person_relationships.from_year/to_year (integer) become
* from_date/to_date (date) + *_date_precision columns, with backfill to
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
* pre-check that aborts the migration on corrupt year data. Mirrors
* {@code PersonBirthDeathMigrationTest} (V76 / ADR-039).
*
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
* database so the staged migrate-to-V77 → seed → migrate-to-latest flow and
* the abort cases cannot interfere with each other. Uses a real Postgres
* container — H2 does not honour CHECK constraints.
*/
class RelationshipMigrationTest {
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
private String dbUrl;
@BeforeAll
static void startContainer() {
POSTGRES.start();
}
@AfterAll
static void stopContainer() {
POSTGRES.stop();
}
@BeforeEach
void createFreshDatabase() throws SQLException {
String dbName = "mig_v78_" + DB_COUNTER.incrementAndGet();
try (Connection conn = DriverManager.getConnection(
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE DATABASE " + dbName);
}
dbUrl = baseUrl(dbName);
}
@Test
void precheck_abortsWhenFromYearAfterToYear() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
seedRelationship(a, b, "SPOUSE_OF", 1958, 1923);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V78 aborted")
.hasMessageContaining("from_year > to_year");
}
@Test
void precheck_abortsWhenYearZeroPresent() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
seedRelationship(a, b, "FRIEND", 0, null);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V78 aborted")
.hasMessageContaining("from_year=0 or to_year=0");
}
@Test
void backfill_fromYearAndToYear_becomeYearPrecisionDates() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
migrateToLatest();
RelationDates row = relationDates(a, b, "SPOUSE_OF");
assertThat(row.fromDate()).hasToString("1923-01-01");
assertThat(row.fromPrecision()).isEqualTo("YEAR");
assertThat(row.toDate()).hasToString("1958-01-01");
assertThat(row.toPrecision()).isEqualTo("YEAR");
}
@Test
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
seedRelationship(a, b, "FRIEND", null, null);
migrateToLatest();
RelationDates row = relationDates(a, b, "FRIEND");
assertThat(row.fromDate()).isNull();
assertThat(row.fromPrecision()).isEqualTo("UNKNOWN");
assertThat(row.toDate()).isNull();
assertThat(row.toPrecision()).isEqualTo("UNKNOWN");
}
@Test
void backfill_preservesRowCount() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
UUID c = seedPerson("Gamma");
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
seedRelationship(a, c, "FRIEND", null, null);
migrateToLatest();
assertThat(countWhere("1 = 1")).isEqualTo(2);
}
@Test
void orderCheckConstraint_rejectsToDateBeforeFromDate() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
migrateToLatest();
assertThatThrownBy(() -> insertDatedRelationship(
a, b, "FRIEND", "1958-01-01", "YEAR", "1923-01-01", "YEAR"))
.hasMessageContaining("chk_relationship_date_order");
}
@Test
void coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
migrateToLatest();
assertThatThrownBy(() -> insertDatedRelationship(
a, b, "FRIEND", "1923-01-01", "UNKNOWN", null, "UNKNOWN"))
.hasMessageContaining("chk_relationship_from_coherence");
}
@Test
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
migrateTo("77");
UUID a = seedPerson("Alpha");
UUID b = seedPerson("Beta");
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
migrateToLatest();
assertThat(columnExists("from_year")).isFalse();
assertThat(columnExists("to_year")).isFalse();
assertThat(columnExists("from_date")).isTrue();
assertThat(columnExists("to_date")).isTrue();
for (String constraint : new String[]{
"chk_relationship_from_coherence",
"chk_relationship_to_coherence",
"chk_relationship_date_order",
"chk_relationship_from_precision_values",
"chk_relationship_to_precision_values"}) {
assertThat(constraintExists(constraint)).as(constraint).isTrue();
}
}
// --- helpers ---
private static String baseUrl(String dbName) {
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
}
private void migrateTo(String targetVersion) {
flywayBuilder().target(targetVersion).load().migrate();
}
private void migrateToLatest() {
flywayBuilder().load().migrate();
}
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
return Flyway.configure()
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", "test-only"));
}
private UUID seedPerson(String lastName) throws SQLException {
UUID id = UUID.randomUUID();
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO persons (id, last_name, person_type, family_member, provisional) "
+ "VALUES (?, ?, 'PERSON', false, false)")) {
stmt.setObject(1, id);
stmt.setString(2, lastName);
stmt.executeUpdate();
}
return id;
}
private void seedRelationship(UUID personId, UUID relatedId, String type, Integer fromYear, Integer toYear)
throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO person_relationships (id, person_id, related_person_id, relation_type, from_year, to_year) "
+ "VALUES (gen_random_uuid(), ?, ?, ?, ?, ?)")) {
stmt.setObject(1, personId);
stmt.setObject(2, relatedId);
stmt.setString(3, type);
stmt.setObject(4, fromYear);
stmt.setObject(5, toYear);
stmt.executeUpdate();
}
}
private void insertDatedRelationship(UUID personId, UUID relatedId, String type,
String fromDate, String fromPrecision,
String toDate, String toPrecision) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO person_relationships "
+ "(id, person_id, related_person_id, relation_type, from_date, from_date_precision, to_date, to_date_precision) "
+ "VALUES (gen_random_uuid(), ?, ?, ?, CAST(? AS date), ?, CAST(? AS date), ?)")) {
stmt.setObject(1, personId);
stmt.setObject(2, relatedId);
stmt.setString(3, type);
stmt.setObject(4, fromDate);
stmt.setString(5, fromPrecision);
stmt.setObject(6, toDate);
stmt.setString(7, toPrecision);
stmt.executeUpdate();
}
}
private record RelationDates(Object fromDate, String fromPrecision, Object toDate, String toPrecision) {}
private RelationDates relationDates(UUID personId, UUID relatedId, String type) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT from_date, from_date_precision, to_date, to_date_precision "
+ "FROM person_relationships WHERE person_id = ? AND related_person_id = ? AND relation_type = ?")) {
stmt.setObject(1, personId);
stmt.setObject(2, relatedId);
stmt.setString(3, type);
try (ResultSet rs = stmt.executeQuery()) {
assertThat(rs.next()).as("relationship exists").isTrue();
return new RelationDates(
rs.getObject("from_date"),
rs.getString("from_date_precision"),
rs.getObject("to_date"),
rs.getString("to_date_precision"));
}
}
}
private long countWhere(String condition) throws SQLException {
try (Connection conn = connect();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM person_relationships WHERE " + condition)) {
rs.next();
return rs.getLong(1);
}
}
private boolean columnExists(String columnName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND table_name = 'person_relationships' AND column_name = ?")) {
stmt.setString(1, columnName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private boolean constraintExists(String constraintName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
stmt.setString(1, constraintName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
}
}

View File

@@ -4,10 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig; import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; 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.relationship.dto.CreateRelationshipRequest; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.person.PersonNameAliasRepository; import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
@@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest {
@Test @Test
void addRelationship_stores_and_is_readable() { void addRelationship_stores_and_is_readable() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null); var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null);
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto); RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull(); assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.getId()); assertThat(created.personId()).isEqualTo(alice.getId());
assertThat(created.relatedPersonId()).isEqualTo(bob.getId()); assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
assertThat(created.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(created.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(created.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId()); List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
assertThat(rels).hasSize(1); assertThat(rels).hasSize(1);
@@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest {
@Test @Test
void addRelationship_throws_409_when_duplicate() { void addRelationship_throws_409_when_duplicate() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null); var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
relationshipService.addRelationship(alice.getId(), dto); relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto)) assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
@@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest {
void addRelationship_throws_409_when_circular_parent() { void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected. // alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(), relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null)); new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null); var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse)) assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -103,28 +109,58 @@ class RelationshipServiceIntegrationTest {
} }
@Test @Test
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() { void deleteRelationship_throws_404_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null)); new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
// Charlie is unrelated to this row. // Charlie is unrelated to this row. Ownership mismatch is 404, not 403, so a
// curator cannot enumerate relationship ids belonging to people they can't see
// (anti-enumeration; aligned with the PUT endpoint — ADR-044).
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id())) assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN); .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
// The row is still there. // The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent(); assertThat(relationshipRepository.findById(created.id())).isPresent();
} }
@Test
void updateRelationship_persists_new_type_dates_and_notes() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null));
RelationshipDTO updated = relationshipService.updateRelationship(alice.getId(), created.id(),
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day"));
assertThat(updated.id()).isEqualTo(created.id());
assertThat(updated.relationType()).isEqualTo(RelationType.SPOUSE_OF);
assertThat(updated.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
assertThat(updated.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(updated.notes()).isEqualTo("wedding day");
}
@Test
void updateRelationship_throws_404_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
assertThatThrownBy(() -> relationshipService.updateRelationship(charlie.getId(), created.id(),
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null)))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
@Test @Test
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() { void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF) // V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF. // and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
relationshipService.addRelationship(alice.getId(), relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null)); new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null); var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse)) assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -135,7 +171,7 @@ class RelationshipServiceIntegrationTest {
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() { void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side. // alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null)); new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
relationshipService.deleteRelationship(bob.getId(), created.id()); relationshipService.deleteRelationship(bob.getId(), created.id());
@@ -148,7 +184,7 @@ class RelationshipServiceIntegrationTest {
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit // edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
// setFamilyMember(true) call below is the thing under test, not the auto-flip. // setFamilyMember(true) call below is the thing under test, not the auto-flip.
relationshipService.addRelationship(alice.getId(), relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null)); new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null));
relationshipService.setFamilyMember(charlie.getId(), false); relationshipService.setFamilyMember(charlie.getId(), false);
NetworkDTO before = relationshipService.getFamilyNetwork(); NetworkDTO before = relationshipService.getFamilyNetwork();
@@ -165,7 +201,7 @@ class RelationshipServiceIntegrationTest {
@Test @Test
void delete_person_cascades_to_relationships() { void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null)); new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
UUID relId = created.id(); UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent(); assertThat(relationshipRepository.findById(relId)).isPresent();

View File

@@ -6,16 +6,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException; 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.relationship.dto.CreateRelationshipRequest; import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO; import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -59,9 +61,9 @@ class RelationshipServiceTest {
charlie = person("Charlie"); charlie = person("Charlie");
} }
// --- Nora blocker 1 --- // --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
@Test @Test
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() { void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID(); UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId); PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel)); when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
@@ -69,7 +71,7 @@ class RelationshipServiceTest {
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId)) assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN); .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
verify(relationshipRepository, never()).delete(any()); verify(relationshipRepository, never()).delete(any());
} }
@@ -82,7 +84,7 @@ class RelationshipServiceTest {
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType( when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true); alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null); var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto)) assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -98,7 +100,7 @@ class RelationshipServiceTest {
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false); bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel")); when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null); var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -107,7 +109,7 @@ class RelationshipServiceTest {
@Test @Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() { void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null); var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
@@ -116,14 +118,42 @@ class RelationshipServiceTest {
} }
@Test @Test
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() { void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
when(personService.getById(alice.getId())).thenReturn(alice); when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob); when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null); var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto)) assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.extracting("code") .extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR); .isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
LocalDate.of(1950, 1, 1), DatePrecision.UNKNOWN, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
null, DatePrecision.DAY, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
verify(relationshipRepository, never()).saveAndFlush(any()); verify(relationshipRepository, never()).saveAndFlush(any());
} }
@@ -140,13 +170,16 @@ class RelationshipServiceTest {
return r; return r;
}); });
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born"); var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
var result = service.addRelationship(alice.getId(), dto); var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId()); assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId()); assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF); assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
assertThat(result.fromYear()).isEqualTo(1900); assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.notes()).isEqualTo("first born"); assertThat(result.notes()).isEqualTo("first born");
} }
@@ -166,7 +199,7 @@ class RelationshipServiceTest {
return r; return r;
}); });
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null); var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
service.addRelationship(alice.getId(), dto); service.addRelationship(alice.getId(), dto);
verify(personService).setFamilyMember(alice.getId(), true); verify(personService).setFamilyMember(alice.getId(), true);
@@ -187,7 +220,7 @@ class RelationshipServiceTest {
return r; return r;
}); });
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null); var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
service.addRelationship(alice.getId(), dto); service.addRelationship(alice.getId(), dto);
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean()); verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
@@ -216,6 +249,131 @@ class RelationshipServiceTest {
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND); .isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
} }
// --- updateRelationship (REQ-004/006/007/008/009/010/013) ---
@Test
void updateRelationship_throws_NOT_FOUND_when_relId_unknown() {
UUID relId = UUID.randomUUID();
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(charlie.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_VALIDATION_ERROR_on_self_relation() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void updateRelationship_throws_DUPLICATE_when_db_constraint_violated() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void updateRelationship_updates_fields_and_returns_dto() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day");
var result = service.updateRelationship(alice.getId(), relId, dto);
assertThat(result.relationType()).isEqualTo(RelationType.SPOUSE_OF);
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.notes()).isEqualTo("wedding day");
}
@Test
void updateRelationship_marks_both_endpoints_family_when_updated_to_family_type() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SIBLING_OF, null, null, null, null, null);
service.updateRelationship(alice.getId(), relId, dto);
verify(personService).setFamilyMember(alice.getId(), true);
verify(personService).setFamilyMember(bob.getId(), true);
}
@Test @Test
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() { void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result). // alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
@@ -260,11 +418,15 @@ class RelationshipServiceTest {
} }
private static PersonRelationship parentOf(Person parent, Person child, UUID id) { private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
return relOf(parent, child, RelationType.PARENT_OF, id);
}
private static PersonRelationship relOf(Person subject, Person object, RelationType type, UUID id) {
return PersonRelationship.builder() return PersonRelationship.builder()
.id(id) .id(id)
.person(parent) .person(subject)
.relatedPerson(child) .relatedPerson(object)
.relationType(RelationType.PARENT_OF) .relationType(type)
.createdAt(Instant.now()) .createdAt(Instant.now())
.build(); .build();
} }

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,61 @@
package org.raddatz.familienarchiv.tag;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Real-Postgres proof that {@link TagService#resolveRootTags} walks a persisted tag chain to its
* true root through the recursive-CTE {@link TagRepository#findAncestorIds}. The CTE cannot run on
* H2, so this uses {@code postgres:16-alpine} via Testcontainers. Exhaustive case coverage lives in
* {@link TagServiceTest} (mocked); this pins the DB-dependent ancestry walk (issue #835, REQ-003/004).
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TagServiceIntegrationTest {
@Autowired private TagRepository tagRepository;
private TagService tagService;
@BeforeEach
void setUp() {
tagService = new TagService(tagRepository);
}
private Tag tag(String name, String color, UUID parentId) {
return tagRepository.save(Tag.builder().name(name).color(color).parentId(parentId).build());
}
@Test
void resolveRootTags_walksPersistedChainToRoot_withRootColor() {
// leaf → mid → root resolves to the root's (id, name, color) via the real recursive CTE.
Tag root = tag("Krieg", "sienna", null);
Tag mid = tag("Feldpost", null, root.getId());
Tag leaf = tag("Briefe von der Front", null, mid.getId());
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(leaf));
assertThat(result.get(leaf.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
}
@Test
void resolveRootTags_returnsRootItself_forPersistedRoot() {
Tag root = tag("Weihnachten", "amber", null);
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Weihnachten", "amber"));
}
}

View File

@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository; import org.raddatz.familienarchiv.tag.TagRepository;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -450,6 +451,74 @@ class TagServiceTest {
assertThat(child2.getColor()).isEqualTo("sienna"); assertThat(child2.getColor()).isEqualTo("sienna");
} }
// ─── resolveRootTags ───────────────────────────────────────────────────────
@Test
void resolveRootTags_returnsTagItself_whenTagIsRoot() {
// REQ-003/004: a root tag (no parent) is its own primary root — no ancestry walk, no load.
Tag root = Tag.builder().id(UUID.randomUUID()).name("Krieg").color("sienna").build();
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
verify(tagRepository, never()).findAncestorIds(any());
verify(tagRepository, never()).findAllById(any());
}
@Test
void resolveRootTags_walksChildToRoot_withRootColor() {
// REQ-003/004: a nested child resolves to its root's id/name/color via one CTE + one batch.
UUID rootId = UUID.randomUUID();
UUID midId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
Tag mid = Tag.builder().id(midId).name("Feldpost").parentId(rootId).build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(midId).build();
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(midId, rootId));
when(tagRepository.findAllById(Set.of(midId, rootId))).thenReturn(List.of(mid, rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
}
@Test
void resolveRootTags_memoizesPerDistinctTag_noNPlusOne() {
// REQ-004: two letters sharing one tag id ⇒ a single findAncestorIds + a single batch load.
UUID rootId = UUID.randomUUID();
UUID childId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
Tag childA = Tag.builder().id(childId).name("Front").parentId(rootId).build();
Tag childB = Tag.builder().id(childId).name("Front").parentId(rootId).build();
when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId));
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(childA, childB));
assertThat(result.get(childId)).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
verify(tagRepository, times(1)).findAncestorIds(childId);
verify(tagRepository, times(1)).findAllById(any());
}
@Test
void resolveRootTags_returnsNullColor_whenRootHasNoColor() {
// REQ-007: a colorless root yields RootTag.color() == null (frontend renders a neutral chip).
UUID rootId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Allgemein").build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Notiz").parentId(rootId).build();
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(rootId));
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Allgemein", null));
}
@Test
void resolveRootTags_returnsEmptyMap_forEmptyInput() {
assertThat(tagService.resolveRootTags(List.of())).isEmpty();
verify(tagRepository, never()).findAncestorIds(any());
}
// ─── mergeTags ──────────────────────────────────────────────────────────── // ─── mergeTags ────────────────────────────────────────────────────────────
@Test @Test

View File

@@ -0,0 +1,402 @@
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 makeSpouseEdgeWithDate(a, b,
fromYear != null ? LocalDate.of(fromYear, 1, 1) : null,
fromYear != null ? DatePrecision.YEAR : DatePrecision.UNKNOWN);
}
private PersonRelationship makeSpouseEdgeWithDate(Person a, Person b, LocalDate fromDate, DatePrecision precision) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(RelationType.SPOUSE_OF)
.fromDate(fromDate)
.fromDatePrecision(precision)
.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-017 (#837): derived Heirat sources SPOUSE_OF.fromDate at its stored precision ---
@Test
void should_emit_day_precision_heirat_from_spouse_fromDate() {
Person anna = makePerson(null, DatePrecision.UNKNOWN);
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
PersonRelationship edge = makeSpouseEdgeWithDate(anna, hans, LocalDate.of(1923, 5, 12), DatePrecision.DAY);
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
TimelineEntryDTO heirat = service.assembleDerivedEvents().stream()
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
.findFirst().orElseThrow();
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1923, 5, 12));
assertThat(heirat.precision()).isEqualTo(DatePrecision.DAY);
}
// --- 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,552 @@
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 org.raddatz.familienarchiv.tag.RootTag;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagService;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TimelineServiceTest {
@Mock TimelineEventRepository eventRepository;
@Mock TimelineEventService timelineEventService;
@Mock DocumentService documentService;
@Mock PersonService personService;
@Mock TagService tagService;
@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,
null, null, null);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
null, null, null);
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 ─────────────────────────────────────────────────────────────
// ─── root-tag chip enrichment (#835) ─────────────────────────────────────
@Test
void letter_with_tags_carries_its_primary_root_tag() {
// REQ-003/006: the primary tag is the root ancestor of the alphabetically-first
// assigned tag ("Briefe von der Front" < "Zeitung"), resolved to root "Krieg".
UUID kriegId = UUID.randomUUID();
Tag front = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(kriegId).build();
Tag zeitung = Tag.builder().id(UUID.randomUUID()).name("Zeitung").build();
Document doc = docWithTags(LocalDate.of(1916, 5, 1), DatePrecision.MONTH, front, zeitung);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
when(tagService.resolveRootTags(anyList()))
.thenReturn(Map.of(front.getId(), new RootTag(kriegId, "Krieg", "sienna")));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.rootTagId()).isEqualTo(kriegId);
assertThat(entry.rootTagName()).isEqualTo("Krieg");
assertThat(entry.rootTagColor()).isEqualTo("sienna");
}
@Test
void untagged_letter_has_no_root_tag_fields() {
// REQ-005: a letter with no tags carries null id/name/color — and never hits TagService.
Document doc = docWithDate(LocalDate.of(1909, 3, 1), DatePrecision.MONTH);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.rootTagId()).isNull();
assertThat(entry.rootTagName()).isNull();
assertThat(entry.rootTagColor()).isNull();
verify(tagService, never()).resolveRootTags(anyList());
}
@Test
void letter_primary_root_without_color_yields_null_color() {
// REQ-007: a colorless root → rootTagColor null, id+name still present (neutral chip).
UUID rootId = UUID.randomUUID();
Tag allgemein = Tag.builder().id(rootId).name("Allgemein").build();
Document doc = docWithTags(LocalDate.of(1910, 2, 1), DatePrecision.MONTH, allgemein);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
when(tagService.resolveRootTags(anyList()))
.thenReturn(Map.of(rootId, new RootTag(rootId, "Allgemein", null)));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.rootTagId()).isEqualTo(rootId);
assertThat(entry.rootTagName()).isEqualTo("Allgemein");
assertThat(entry.rootTagColor()).isNull();
}
@Test
void root_tags_resolved_in_a_single_batched_pass() {
// REQ-004: many letters → exactly one resolveRootTags call (no per-letter N+1).
UUID kriegId = UUID.randomUUID();
Tag krieg = Tag.builder().id(kriegId).name("Krieg").color("sienna").build();
Tag weihnachten = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").color("amber").build();
Document a = docWithTags(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, krieg);
Document b = docWithTags(LocalDate.of(1916, 12, 1), DatePrecision.MONTH, weihnachten);
Document c = docWithTags(LocalDate.of(1917, 1, 1), DatePrecision.YEAR, krieg);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(a, b, c));
when(tagService.resolveRootTags(anyList())).thenReturn(Map.of(
kriegId, new RootTag(kriegId, "Krieg", "sienna"),
weihnachten.getId(), new RootTag(weihnachten.getId(), "Weihnachten", "amber")));
timelineService.assemble(noFilters());
verify(tagService, times(1)).resolveRootTags(anyList());
}
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0);
}
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,
null, null, 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 docWithTags(LocalDate date, DatePrecision precision, Tag... tags) {
return Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(precision).documentDate(date)
.tags(new HashSet<>(Set.of(tags))).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

@@ -538,6 +538,29 @@ pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.) (For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
### Deploy note — V78 (person_relationships from/to → date + precision, #837)
V78 drops `person_relationships.from_year`/`to_year` after backfilling the new
`from_date`/`to_date` + precision columns — a **one-way migration** (Flyway cannot roll
it back). Like V76 it runs its pre-check + DDL in one atomic Flyway transaction and
needs **no maintenance window** (single-writer archive, no concurrent importers).
It is, however, **not rolling-deploy-safe**: the previously-running JAR still maps the
`from_year`/`to_year` columns, so it would error against the migrated schema. Deploy in
this order (the default stop-then-start, single-instance deploy already satisfies it):
1. Take a manual `pg_dump` (see above) and confirm it completed.
2. **Stop the old JAR**, then **start the new JAR** — Flyway V78 runs first thing on the
new JAR's startup, before any request is served. Never run the old and new JARs
concurrently across this migration.
If post-deploy data issues are found, restore **only the person_relationships table**
from the pre-migration dump:
```bash
pg_restore -t person_relationships -d ${POSTGRES_DB} backup-YYYYMMDD.dump
```
### Rollback ### Rollback
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command: Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:

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

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

@@ -0,0 +1,91 @@
# ADR-044 — Relationship dates become LocalDate + DatePrecision; relationships become editable
**Status:** Accepted
**Date:** 2026-06-14
**Issue:** #837 (Zeitstrahl milestone; deferred follow-up to #773 / ADR-039)
## Context
`PersonRelationship` stored its span as `Integer fromYear`/`toYear`. A wedding could
never be more precise than `1923`, while `Person` (ADR-039), `Document`, and
`TimelineEvent` already carry full `DatePrecision`. Relationships also supported only
create + delete: fixing a wrong type, a wrong person, or adding a date learned later
meant deleting and re-creating the edge — losing `createdAt`. A `notes` column existed
that no form set and nothing displayed.
V78 replaces the two integer columns with `from_date`/`to_date` (`DATE`, nullable) plus
`from_date_precision`/`to_date_precision` (`VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'`),
backfilling existing years as `YYYY-01-01` at `YEAR` precision — exactly the V76 / ADR-039
pattern applied to the relationship edge. A new `PUT /api/persons/{id}/relationships/{relId}`
makes relationships editable, and `notes` is activated end to end.
## Decisions
### 1. Mirror ADR-039 verbatim for the relationship edge
`DatePrecision` is imported cross-domain from `document/` (ADR-039 §1 — value-type
sharing, not a layering breach). The precision columns are NOT NULL default `UNKNOWN`,
guarded by five named CHECK constraints (`chk_relationship_from_coherence`,
`chk_relationship_to_coherence`, `chk_relationship_date_order`,
`chk_relationship_{from,to}_precision_values`). `RelationshipService.validateRelationshipDates`
enforces the same rules first, so the user gets a structured 400
(`INVALID_DATE_PRECISION` for coherence, the new `INVALID_RELATIONSHIP_DATES` for a
`toDate < fromDate` order violation) instead of a constraint-violation 500. The form
offers only **DAY / MONTH / YEAR**; storage still accepts all seven values, and a
stored non-offered precision seeds the edit select as `YEAR` (ADR-039 §2).
### 2. Update re-runs every create invariant
An edit can violate the same invariants as a create, so `updateRelationship` re-runs
all of them: self-relation (`VALIDATION_ERROR`), date coherence + order, reverse
`PARENT_OF` (`CIRCULAR_RELATIONSHIP`), and the `(person, relatedPerson, type)` unique
constraint via `saveAndFlush` (`DUPLICATE_RELATIONSHIP`). Editing into a family type
flags both endpoints as family members (additive; never auto-unflags). The directed
orientation is preserved per viewpoint — whichever endpoint `{personId}` already holds
on the row stays put — so a `PARENT_OF` edge remains parent→child whether edited from
either person's page.
### 3. No optimistic locking (`@Version`)
`PersonRelationship` gains no `@Version`; the edit is last-write-wins, matching the
person edit form. This is a single-writer family archive, and it avoids the managed-
`setVersion` pitfall (a `setVersion` on a managed entity is silently ignored by
Hibernate — see the integration-test note in #496-era work). If concurrent curation
ever becomes real, add `@Version` plus an explicit client-version compare then.
### 4. IDOR / anti-enumeration: ownership mismatch is 404, for PUT **and** DELETE
A `{relId}` that does not belong to `{personId}` returns 404 `RELATIONSHIP_NOT_FOUND`
(a shared `loadOwnedRelationship` helper), so a curator cannot probe relationship ids
belonging to people they cannot see. This **aligns `deleteRelationship`** from its
former 403 to 404 in the same change, so the two mutating endpoints behave identically
on the same mismatch.
### 5. Derived marriage events gain precision for free
`TimelineEventService.buildMarriageEvents` now sources the Heirat date from the
`SPOUSE_OF` row's `from_date` + `from_date_precision` (previously
`LocalDate.of(fromYear, 1, 1)` at hard-coded `YEAR`). A DAY-precision wedding now
surfaces the exact day on the Zeitstrahl. `RelationshipInferenceService` is unchanged
— it is time-ignorant and never read the year fields.
### 6. `relationshipDates.ts` lives in `$lib/person/`, no new boundary
`formatRelationshipDateRange` mirrors `personLifeDates.ts` and delegates entirely to the
already-tested `formatDocumentDate` (zero new precision logic). It sits in `$lib/person/`
next to `personLifeDates.ts`; its only cross-domain import is `formatDocumentDate` from
`$lib/shared/utils/`, which the existing `person → shared` rule in `eslint.config.js`
already permits — **no new eslint boundary rule is added**.
## Consequences
- V78 is one-way (columns dropped) and is **not** rolling-deploy-safe — the running JAR
maps `from_year` until redeploy. Deploy order: **stop old JAR → run Flyway V78 →
start new JAR**. Rollback = targeted `pg_restore -t person_relationships` from the
pre-deploy dump (see `docs/DEPLOYMENT.md` §8). No maintenance window needed
(single-writer archive).
- Relationships are fully editable (type, related person, dates, notes) and the read
view shows the date range + notes.
- `RelationshipDTO` drops `fromYear`/`toYear` for `fromDate`/`fromDatePrecision`/
`toDate`/`toDatePrecision`; the `personBirthYear`/`relatedPersonBirthYear` derived
fields are unaffected (ADR-039 §3).

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,8 @@ 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(zeitstrahlEvents, "/zeitstrahl/events/new and /zeitstrahl/events/[id]/edit", "SvelteKit Routes", "Curator event editor (WRITE_ALL-gated via server load, 403 error page). One lib/timeline/EventForm for both routes: title, EventTypeSelect (PERSONAL/HISTORICAL segmented radio), shared DatePrecisionField (RANGE reveals end date), plain-text description, PersonMultiSelect + DocumentMultiSelect. New: ?personId/?documentId prefill via Promise.all (404/403 swallowed), POST /api/timeline/events. Edit: load seeds from GET /api/timeline/events/{id} (404 on any non-ok — fails closed against derived events), PUT (optimistic-lock version) + DELETE behind ConfirmDialog. Context-aware redirect via UUID-validated originPersonId.")
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 +29,9 @@ 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(zeitstrahlEvents, backend, "GET /api/timeline/events/{id}, POST /api/timeline/events, PUT/DELETE /api/timeline/events/{id}, GET /api/persons/{id} + /api/documents/{id} (prefill)", "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

@@ -1,6 +1,6 @@
@startuml db-orm @startuml db-orm
' Schema source: Flyway V1V77 (excl. V37, V43 — intentionally removed) ' Schema source: Flyway V1V78 (excl. V37, V43 — intentionally removed)
' Schema as of: V77 (2026-06-12) ' Schema as of: V78 (2026-06-14)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly. ' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle hide circle
@@ -211,8 +211,10 @@ package "Persons" {
person_id : UUID <<FK>> person_id : UUID <<FK>>
related_person_id : UUID <<FK>> related_person_id : UUID <<FK>>
relation_type : VARCHAR(30) NOT NULL relation_type : VARCHAR(30) NOT NULL
from_year : INTEGER from_date : DATE
to_year : INTEGER from_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
to_date : DATE
to_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
notes : VARCHAR(2000) notes : VARCHAR(2000)
created_at : TIMESTAMPTZ NOT NULL created_at : TIMESTAMPTZ NOT NULL
} }

View File

@@ -7,6 +7,8 @@
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date + ' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
' precision columns; columns only, no new FK relationships, diagram unchanged. ' precision columns; columns only, no new FK relationships, diagram unchanged.
' Note: V77 adds the timeline_events table + two join tables (Timeline package below). ' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
' Note: V78 swaps person_relationships.from_year/to_year for from_date/to_date +
' precision columns; columns only, no new FK relationships, diagram unchanged.
hide circle hide circle
skinparam linetype ortho skinparam linetype ortho

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; events/new + events/[id]/edit curator editor (WRITE_ALL-gated)
│ ├── 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

@@ -26,7 +26,7 @@ test.describe('Document auto-title sync (#726)', () => {
// 3. Add a YEAR-precision date WITHOUT touching the title, then save. // 3. Add a YEAR-precision date WITHOUT touching the title, then save.
await page.locator('#documentDate').fill('15.01.1928'); await page.locator('#documentDate').fill('15.01.1928');
await page.locator('#metaDatePrecision').selectOption('YEAR'); await page.locator('#documentDatePrecision').selectOption('YEAR');
await page.getByRole('button', { name: 'Speichern', exact: true }).click(); await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// 4. The detail page shows the regenerated title carrying the new year. // 4. The detail page shows the regenerated title carrying the new year.

View File

@@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';
/**
* Curator timeline event editor (#781) — intentionally thin. The component +
* server specs carry the real regression coverage (they run in CI's "Unit &
* Component Tests" job); ci.yml does NOT invoke test:e2e today, so this file
* runs only locally/manually against the full Docker Compose stack.
*
* Three checks: one critical create journey (→ HTTP 200 on /zeitstrahl; the full
* "sees the event card" assertion depends on #7), one security counterpart
* (logged-out → 403), and one 320px no-overflow guarantee for the 60+ author
* audience.
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
test.describe('Curator creates a timeline event', () => {
test('fills the create form with precision RANGE and lands on /zeitstrahl (HTTP 200)', async ({
page
}) => {
await page.goto('/zeitstrahl/events/new');
await page.getByLabel(/Titel/i).fill(`E2E Ereignis ${stamp()}`);
await page.getByRole('radio', { name: /Historisch/i }).click();
// Date + RANGE end date via the shared German dd.mm.yyyy inputs.
await page.locator('#eventDate').fill('01.04.1925');
await page.locator('#eventDatePrecision').selectOption('RANGE');
await expect(page.getByLabel('Enddatum')).toBeVisible();
await page.locator('#eventDateEnd').fill('01.05.1925');
// Submitting redirects to the resolved nav target (/zeitstrahl) — assert the
// route responds 200, not a DOM card (card rendering is #7's concern).
await Promise.all([
page.waitForURL(/\/zeitstrahl$/),
page.getByRole('button', { name: 'Speichern' }).click()
]);
const response = await page.goto('/zeitstrahl');
expect(response?.status()).toBe(200);
});
});
test.describe('Logged-out user is blocked from the curator route', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('navigating to /zeitstrahl/events/new is blocked with 403', async ({ page }) => {
await page.goto('/zeitstrahl/events/new');
// The load guard throws 403 before any form renders.
await expect(page.getByLabel(/Titel/i)).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText(/403|Zugriff verweigert|Forbidden/i)).toBeVisible({
timeout: 5000
});
});
});
test.describe('Responsive — 60+ author audience', () => {
test('no horizontal overflow on the create form at 320px', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 900 });
await page.goto('/zeitstrahl/events/new');
await expect(page.getByLabel(/Titel/i)).toBeVisible();
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
expect(scrollWidth).toBe(320);
});
});

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

@@ -199,7 +199,12 @@ export default defineConfig(
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'timeline' }, allow: { to: { type: ['shared'] } } }, // Timeline curator event editor selects persons and documents by
// design (mirrors the geschichte editor) — #781.
{
from: { type: 'timeline' },
allow: { to: { type: ['shared', 'person', 'document'] } }
},
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
{ {
from: { type: 'routes' }, from: { type: 'routes' },
@@ -215,6 +220,7 @@ export default defineConfig(
'ocr', 'ocr',
'activity', 'activity',
'conversation', 'conversation',
'timeline',
'shared' 'shared'
] ]
} }

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.", "error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.", "error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.", "error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
"error_invalid_relationship_dates": "Das Ende-Datum darf nicht vor dem Beginn-Datum liegen.",
"validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
@@ -1032,6 +1033,56 @@
"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",
"timeline_grouping_date": "Gruppierung: Datum",
"timeline_provenance_derived": "abgeleitet",
"timeline_provenance_curated": "kuratiert",
"timeline_letter_glyph_label": "Brief",
"timeline_tag_chip_label": "Thema",
"timeline_layer_historical_suffix": "historisch",
"timeline_strip_density_caption": "Monats-Dichte",
"timeline_events_count": "{count} Ereignisse",
"timeline_letters_count_singular": "1 Brief",
"timeline_events_count_singular": "1 Ereignis",
"event_editor_new_title": "Neues Ereignis",
"event_editor_edit_title": "Ereignis bearbeiten",
"event_editor_section_when": "Wann",
"event_editor_section_persons": "Beteiligte Personen",
"event_editor_section_documents": "Verknüpfte Briefe",
"event_editor_section_description": "Beschreibung",
"event_editor_title_label": "Titel",
"event_editor_title_placeholder": "Titel des Ereignisses",
"event_editor_title_required": "Bitte einen Titel eingeben.",
"event_editor_date_required": "Bitte ein Datum eingeben.",
"event_editor_end_date_required": "Bitte ein Enddatum eingeben.",
"event_editor_type_label": "Typ",
"event_editor_persons_label": "Personen",
"event_editor_documents_label": "Briefe",
"event_editor_description_label": "Beschreibung",
"event_editor_description_placeholder": "Optionale Beschreibung",
"event_editor_persons_empty": "Noch keine Person verknüpft",
"event_editor_documents_empty": "Noch kein Dokument verknüpft",
"event_type_PERSONAL": "Persönlich",
"event_type_HISTORICAL": "Historisch",
"event_editor_save": "Speichern",
"event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.",
"event_editor_delete": "Löschen",
"event_editor_delete_confirm_title": "Ereignis löschen?",
"event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.",
"event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"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.",
@@ -1171,6 +1222,16 @@
"relation_form_field_from_year": "Von Jahr", "relation_form_field_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr", "relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920", "relation_form_year_placeholder": "z.B. 1920",
"relation_label_from_date": "Beginn (Datum)",
"relation_label_to_date": "Ende (Datum)",
"relation_label_date_precision": "Genauigkeit",
"relation_precision_day": "Genaues Datum (Tag)",
"relation_precision_month": "Monat bekannt",
"relation_precision_year": "Nur Jahreszahl",
"relation_label_notes": "Notizen",
"relation_notes_placeholder": "Optionaler Hinweis zu dieser Beziehung",
"relation_date_placeholder_hint": "Leer lassen, wenn unbekannt",
"relation_edit": "Beziehung bearbeiten",
"person_relationships_heading": "Beziehungen", "person_relationships_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt.", "person_relationships_empty": "Noch keine Beziehungen bekannt.",
"timeline_aria_label": "Zeitachse Dokumentdichte", "timeline_aria_label": "Zeitachse Dokumentdichte",

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "The end date must not be before the start date.", "error_invalid_date_range": "The end date must not be before the start date.",
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.", "error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
"error_invalid_date_precision": "Date and precision do not match.", "error_invalid_date_precision": "Date and precision do not match.",
"error_invalid_relationship_dates": "The end date must not be before the start date.",
"validation_last_name_required": "Last name is required.", "validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.", "validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.", "error_ocr_service_unavailable": "The OCR service is not available.",
@@ -1032,6 +1033,56 @@
"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",
"timeline_grouping_date": "Grouping: Date",
"timeline_provenance_derived": "derived",
"timeline_provenance_curated": "curated",
"timeline_letter_glyph_label": "Letter",
"timeline_tag_chip_label": "Topic",
"timeline_layer_historical_suffix": "historical",
"timeline_strip_density_caption": "Monthly density",
"timeline_events_count": "{count} events",
"timeline_letters_count_singular": "1 letter",
"timeline_events_count_singular": "1 event",
"event_editor_new_title": "New event",
"event_editor_edit_title": "Edit event",
"event_editor_section_when": "When",
"event_editor_section_persons": "People involved",
"event_editor_section_documents": "Linked letters",
"event_editor_section_description": "Description",
"event_editor_title_label": "Title",
"event_editor_title_placeholder": "Event title",
"event_editor_title_required": "Please enter a title.",
"event_editor_date_required": "Please enter a date.",
"event_editor_end_date_required": "Please enter an end date.",
"event_editor_type_label": "Type",
"event_editor_persons_label": "People",
"event_editor_documents_label": "Letters",
"event_editor_description_label": "Description",
"event_editor_description_placeholder": "Optional description",
"event_editor_persons_empty": "No person linked yet",
"event_editor_documents_empty": "No document linked yet",
"event_type_PERSONAL": "Personal",
"event_type_HISTORICAL": "Historical",
"event_editor_save": "Save",
"event_editor_save_hint": "Events appear on the timeline.",
"event_editor_delete": "Delete",
"event_editor_delete_confirm_title": "Delete event?",
"event_editor_delete_confirm_body": "This event will be permanently removed.",
"event_editor_unsaved_changes": "You have unsaved changes — really leave?",
"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.",
@@ -1171,6 +1222,16 @@
"relation_form_field_from_year": "From year", "relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year", "relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920", "relation_form_year_placeholder": "e.g. 1920",
"relation_label_from_date": "Start date",
"relation_label_to_date": "End date",
"relation_label_date_precision": "Precision",
"relation_precision_day": "Exact date (day)",
"relation_precision_month": "Month known",
"relation_precision_year": "Year only",
"relation_label_notes": "Notes",
"relation_notes_placeholder": "Optional note about this relationship",
"relation_date_placeholder_hint": "Leave empty if unknown",
"relation_edit": "Edit relationship",
"person_relationships_heading": "Relationships", "person_relationships_heading": "Relationships",
"person_relationships_empty": "No relationships known yet.", "person_relationships_empty": "No relationships known yet.",
"timeline_aria_label": "Document density timeline", "timeline_aria_label": "Document density timeline",

View File

@@ -651,6 +651,7 @@
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.", "error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.", "error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
"error_invalid_date_precision": "La fecha y la precisión no coinciden.", "error_invalid_date_precision": "La fecha y la precisión no coinciden.",
"error_invalid_relationship_dates": "La fecha de fin no puede ser anterior a la de inicio.",
"validation_last_name_required": "El apellido es obligatorio.", "validation_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.",
@@ -1032,6 +1033,56 @@
"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",
"timeline_grouping_date": "Agrupación: Fecha",
"timeline_provenance_derived": "derivado",
"timeline_provenance_curated": "curado",
"timeline_letter_glyph_label": "Carta",
"timeline_tag_chip_label": "Tema",
"timeline_layer_historical_suffix": "histórico",
"timeline_strip_density_caption": "Densidad mensual",
"timeline_events_count": "{count} eventos",
"timeline_letters_count_singular": "1 carta",
"timeline_events_count_singular": "1 evento",
"event_editor_new_title": "Nuevo evento",
"event_editor_edit_title": "Editar evento",
"event_editor_section_when": "Cuándo",
"event_editor_section_persons": "Personas involucradas",
"event_editor_section_documents": "Cartas vinculadas",
"event_editor_section_description": "Descripción",
"event_editor_title_label": "Título",
"event_editor_title_placeholder": "Título del evento",
"event_editor_title_required": "Por favor, introduzca un título.",
"event_editor_date_required": "Por favor, introduzca una fecha.",
"event_editor_end_date_required": "Por favor, introduzca una fecha de fin.",
"event_editor_type_label": "Tipo",
"event_editor_persons_label": "Personas",
"event_editor_documents_label": "Cartas",
"event_editor_description_label": "Descripción",
"event_editor_description_placeholder": "Descripción opcional",
"event_editor_persons_empty": "Aún no hay ninguna persona vinculada",
"event_editor_documents_empty": "Aún no hay ningún documento vinculado",
"event_type_PERSONAL": "Personal",
"event_type_HISTORICAL": "Histórico",
"event_editor_save": "Guardar",
"event_editor_save_hint": "Los eventos aparecen en la cronología.",
"event_editor_delete": "Eliminar",
"event_editor_delete_confirm_title": "¿Eliminar evento?",
"event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.",
"event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?",
"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.",
@@ -1171,6 +1222,16 @@
"relation_form_field_from_year": "Desde año", "relation_form_field_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año", "relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920", "relation_form_year_placeholder": "ej. 1920",
"relation_label_from_date": "Fecha de inicio",
"relation_label_to_date": "Fecha de fin",
"relation_label_date_precision": "Precisión",
"relation_precision_day": "Fecha exacta (día)",
"relation_precision_month": "Mes conocido",
"relation_precision_year": "Solo año",
"relation_label_notes": "Notas",
"relation_notes_placeholder": "Nota opcional sobre esta relación",
"relation_date_placeholder_hint": "Dejar vacío si es desconocido",
"relation_edit": "Editar relación",
"person_relationships_heading": "Relaciones", "person_relationships_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones.", "person_relationships_empty": "Aún no se conocen relaciones.",
"timeline_aria_label": "Cronología de densidad de documentos", "timeline_aria_label": "Cronología de densidad de documentos",

View File

@@ -11,12 +11,21 @@ interface Props {
selectedDocuments?: DocumentOption[]; selectedDocuments?: DocumentOption[];
placeholder?: string; placeholder?: string;
hiddenInputName?: string; hiddenInputName?: string;
/** Empty-state text shown inside the chip container when nothing is selected. */
emptyLabel?: string;
/** id of the search input so a <label for=...> can be associated. */
inputId?: string;
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
onchange?: () => void;
} }
let { let {
selectedDocuments = $bindable([]), selectedDocuments = $bindable([]),
placeholder = m.geschichte_editor_search_document(), placeholder = m.geschichte_editor_search_document(),
hiddenInputName = 'documentIds' hiddenInputName = 'documentIds',
emptyLabel = undefined,
inputId = undefined,
onchange = undefined
}: Props = $props(); }: Props = $props();
let searchTerm = $state(''); let searchTerm = $state('');
@@ -48,10 +57,12 @@ function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc]; selectedDocuments = [...selectedDocuments, doc];
searchTerm = ''; searchTerm = '';
picker.close(); picker.close();
onchange?.();
} }
function removeDocument(id: string | undefined) { function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id); selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
onchange?.();
} }
</script> </script>
@@ -73,7 +84,7 @@ function removeDocument(id: string | undefined) {
<button <button
type="button" type="button"
onclick={() => removeDocument(doc.id)} onclick={() => removeDocument(doc.id)}
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none" class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-label={m.comp_multiselect_remove()} aria-label={m.comp_multiselect_remove()}
> >
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -88,8 +99,13 @@ function removeDocument(id: string | undefined) {
</span> </span>
{/each} {/each}
{#if emptyLabel && selectedDocuments.length === 0}
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
{/if}
<input <input
bind:this={inputEl} bind:this={inputEl}
id={inputId}
type="text" type="text"
autocomplete="off" autocomplete="off"
bind:value={searchTerm} bind:value={searchTerm}

View File

@@ -157,4 +157,14 @@ describe('DocumentMultiSelect — remove', () => {
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]') document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
).toBeNull(); ).toBeNull();
}); });
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
it('renders a ≥44px touch target on the chip remove button', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief A')]
});
const removeBtn = (await page.getByLabelText('Entfernen').element()) as HTMLElement;
expect(removeBtn.className).toContain('min-h-[44px]');
expect(removeBtn.className).toContain('min-w-[44px]');
});
}); });

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,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte'; import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte'; import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date'; import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate'; import type { DatePrecision } from '$lib/shared/utils/documentDate';
@@ -37,64 +36,6 @@ let {
hideDate?: boolean; hideDate?: boolean;
editMode?: boolean; editMode?: boolean;
} = $props(); } = $props();
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.date_precision_option_day },
{ value: 'MONTH', label: m.date_precision_option_month },
{ value: 'SEASON', label: m.date_precision_option_season },
{ value: 'YEAR', label: m.date_precision_option_year },
{ value: 'RANGE', label: m.date_precision_option_range },
{ value: 'APPROX', label: m.date_precision_option_approx },
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
];
const showEndDate = $derived(precision === 'RANGE');
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
// and is then user-driven. onMount runs exactly once, so this never stomps
// the parent's dateIso on a later prop change.
let dateDisplay = $state('');
let dateDirty = $state(false);
let endDisplay = $state('');
onMount(() => {
const seed = dateIso || initialDateIso;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
if (endDateIso) endDisplay = isoToGerman(endDateIso);
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
// lexicographically, so no Date object is needed. Server stays the gate —
// this only surfaces the error early; it never disables Save.
const endBeforeStart = $derived(
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
);
function handleDateInput(e: Event) {
const result = handleGermanDateInput(e);
dateDisplay = result.display;
dateIso = result.iso;
dateDirty = true;
}
function handleEndDateInput(e: Event) {
const result = handleGermanDateInput(e);
endDisplay = result.display;
endDateIso = result.iso;
}
$effect(() => {
const suggested = suggestedDateIso;
if (suggested && !untrack(() => dateDirty)) {
dateDisplay = isoToGerman(suggested);
dateIso = suggested;
}
});
</script> </script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -104,79 +45,22 @@ $effect(() => {
<div class="grid grid-cols-1 gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 gap-5 md:grid-cols-2">
{#if !hideDate} {#if !hideDate}
<!-- Datum (required — row 1, col 1) --> <!-- Datum + Präzision + Enddatum (shared primitive, #781). The three grid
<div data-testid="who-when-date"> cells slot directly into this grid; testids are forwarded so the
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2" existing WhoWhenSection selectors survive the extraction. -->
>{m.form_label_date()}*</label <DatePrecisionField
> bind:dateIso={dateIso}
<input bind:precision={precision}
id="documentDate" bind:endDateIso={endDateIso}
type="text" initialDateIso={initialDateIso}
inputmode="numeric" suggestedDateIso={suggestedDateIso}
value={dateDisplay} dateInputName="documentDate"
oninput={handleDateInput} endDateInputName="metaDateEnd"
placeholder={m.form_placeholder_date()} dateLabel={m.form_label_date()}
maxlength="10" dateTestId="who-when-date"
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm precisionTestId="who-when-precision"
{dateInvalid endDateInnerTestId="who-when-end-date"
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' />
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
<!-- Datumsgenauigkeit (precision) -->
<div data-testid="who-when-precision">
<label for="metaDatePrecision" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_precision()}
</label>
<select
id="metaDatePrecision"
name="metaDatePrecision"
bind:value={precision}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#each PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
<div aria-live="polite">
{#if showEndDate}
<div data-testid="who-when-end-date">
<label for="metaDateEnd" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_end()}
</label>
<input
id="metaDateEnd"
type="text"
inputmode="numeric"
value={endDisplay}
oninput={handleEndDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
aria-invalid={endBeforeStart ? 'true' : undefined}
aria-describedby={endBeforeStart ? 'end-date-error' : undefined}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{endBeforeStart
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
/>
{#if endBeforeStart}
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
<p id="end-date-error" class="mt-1 text-xs text-red-600">
<span aria-hidden="true"></span>{m.error_invalid_date_range()}
</p>
{/if}
</div>
{/if}
</div>
<input type="hidden" name="metaDateEnd" value={showEndDate ? endDateIso : ''} />
{/if} {/if}
<!-- Absender (required in upload mode — row 1, col 2) --> <!-- Absender (required in upload mode — row 1, col 2) -->

View File

@@ -39,4 +39,17 @@ describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', (
const locationInput = document.querySelector('input#location') as HTMLInputElement; const locationInput = document.querySelector('input#location') as HTMLInputElement;
expect(locationInput.value).toBe('Berlin'); expect(locationInput.value).toBe('Berlin');
}); });
// Regression fence for the DatePrecisionField extraction (#781): the existing
// spec covered only date pre-fill / hideDate / location, so the RANGE end-date
// reveal had no red signal. This test must stay green across the extraction.
it('reveals the end-date field when precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'RANGE' });
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
});
it('hides the end-date field when precision is not RANGE', async () => {
render(WhoWhenSection, { precision: 'YEAR' });
await expect.element(page.getByTestId('who-when-end-date')).not.toBeInTheDocument();
});
}); });

View File

@@ -15,14 +15,14 @@ describe('WhoWhenSection — date input behavior', () => {
await vi.waitFor(() => { await vi.waitFor(() => {
// Invalid → border-red-400 class // Invalid → border-red-400 class
expect(dateInput.className).toContain('border-red-400'); expect(dateInput.className).toContain('border-red-400');
expect(document.querySelector('#date-error')).not.toBeNull(); expect(document.querySelector('#documentDate-error')).not.toBeNull();
}); });
}); });
it('does not show the error before the user has typed', async () => { it('does not show the error before the user has typed', async () => {
render(WhoWhenSection, {}); render(WhoWhenSection, {});
const error = document.querySelector('#date-error'); const error = document.querySelector('#documentDate-error');
expect(error).toBeNull(); expect(error).toBeNull();
}); });
@@ -77,20 +77,20 @@ describe('WhoWhenSection — precision controls', () => {
it('renders a labelled precision select', async () => { it('renders a labelled precision select', async () => {
render(WhoWhenSection, {}); render(WhoWhenSection, {});
const label = document.querySelector('label[for="metaDatePrecision"]'); const label = document.querySelector('label[for="documentDatePrecision"]');
const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]'); const select = document.querySelector('select#documentDatePrecision[name="metaDatePrecision"]');
expect(label).not.toBeNull(); expect(label).not.toBeNull();
expect(select).not.toBeNull(); expect(select).not.toBeNull();
}); });
it('hides the end-date field unless precision is RANGE', async () => { it('hides the end-date field unless precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'DAY' }); render(WhoWhenSection, { precision: 'DAY' });
expect(document.querySelector('input#metaDateEnd')).toBeNull(); expect(document.querySelector('input#documentDateEnd')).toBeNull();
}); });
it('reveals the end-date field when precision is RANGE', async () => { it('reveals the end-date field when precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'RANGE' }); render(WhoWhenSection, { precision: 'RANGE' });
expect(document.querySelector('input#metaDateEnd')).not.toBeNull(); expect(document.querySelector('input#documentDateEnd')).not.toBeNull();
}); });
it('never renders the raw cell, and never re-submits it via a hidden input', async () => { it('never renders the raw cell, and never re-submits it via a hidden input', async () => {
@@ -110,9 +110,9 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10' endDateIso: '1917-01-10'
}); });
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
await vi.waitFor(() => { await vi.waitFor(() => {
expect(document.querySelector('#end-date-error')).not.toBeNull(); expect(document.querySelector('#documentDate-end-error')).not.toBeNull();
expect(end.getAttribute('aria-invalid')).toBe('true'); expect(end.getAttribute('aria-invalid')).toBe('true');
expect(end.className).toContain('border-red-400'); expect(end.className).toContain('border-red-400');
}); });
@@ -125,14 +125,16 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10' endDateIso: '1917-01-10'
}); });
await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull()); await vi.waitFor(() =>
expect(document.querySelector('#documentDate-end-error')).not.toBeNull()
);
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
end.value = '12.01.1917'; // now after the start end.value = '12.01.1917'; // now after the start
end.dispatchEvent(new Event('input', { bubbles: true })); end.dispatchEvent(new Event('input', { bubbles: true }));
await vi.waitFor(() => { await vi.waitFor(() => {
expect(document.querySelector('#end-date-error')).toBeNull(); expect(document.querySelector('#documentDate-end-error')).toBeNull();
expect(end.getAttribute('aria-invalid')).not.toBe('true'); expect(end.getAttribute('aria-invalid')).not.toBe('true');
}); });
}); });
@@ -144,6 +146,6 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10' endDateIso: '1917-01-10'
}); });
expect(document.querySelector('#end-date-error')).toBeNull(); expect(document.querySelector('#documentDate-end-error')).toBeNull();
}); });
}); });

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { formatDocumentOption, type DocumentOption } from './documentTypeahead';
describe('formatDocumentOption', () => {
it('returns the bare title when no documentDate is present', () => {
const doc: DocumentOption = { id: 'd1', title: 'Brief ohne Datum' };
expect(formatDocumentOption(doc)).toBe('Brief ohne Datum');
});
// #781: a TimelineEvent's DocumentRef carries documentDate but no precision.
// Missing precision must degrade to the full date (DAY), never the UNKNOWN label.
it('renders the full date when precision is absent (DocumentRef chip)', () => {
const doc: DocumentOption = { id: 'd1', title: 'Umzugsbrief', documentDate: '1925-04-01' };
const label = formatDocumentOption(doc);
expect(label.startsWith('Umzugsbrief · ')).toBe(true);
expect(label).toContain('1925');
// The undefined-precision fallback would otherwise surface the UNKNOWN word.
expect(label.toLowerCase()).not.toContain('unbekannt');
});
});

View File

@@ -5,13 +5,21 @@ import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
export type DocumentOption = Pick< /**
DocumentListItem, * Chip/dedup contract for document pickers. `metaDatePrecision`/`metaDateEnd`
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd' * are optional: the typeahead always populates them, but a TimelineEvent's
>; * DocumentRef (#781) carries only id/title/documentDate — formatDocumentOption
* degrades gracefully (bare title or plain date) when precision is absent.
*/
export type DocumentOption = Pick<DocumentListItem, 'id' | 'title' | 'documentDate'> &
Partial<Pick<DocumentListItem, 'metaDatePrecision' | 'metaDateEnd'>>;
export function createDocumentTypeahead() { export function createDocumentTypeahead() {
return createTypeahead<DocumentOption>({ return createTypeahead<DocumentOption>({
// Intentional bare browser fetch (matches the Geschichte editor): in dev the
// Vite proxy forwards /api and injects the auth header; in prod the app is
// same-origin so the auth cookie travels automatically. An internal
// +server.ts proxy would add complexity with no practical security benefit.
fetchUrl: (q) => fetchUrl: (q) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`) fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
.then((r) => { .then((r) => {
@@ -34,9 +42,12 @@ export function createDocumentTypeahead() {
export function formatDocumentOption(doc: DocumentOption): string { export function formatDocumentOption(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title; if (!doc.documentDate) return doc.title;
// A DocumentRef (#781 timeline chips) carries documentDate but no precision —
// default to DAY so the full date renders, rather than the UNKNOWN fallback
// formatDocumentDate would otherwise hit for an undefined precision.
const label = formatDocumentDate( const label = formatDocumentDate(
doc.documentDate, doc.documentDate,
doc.metaDatePrecision as DatePrecision, (doc.metaDatePrecision as DatePrecision) ?? 'DAY',
doc.metaDateEnd, doc.metaDateEnd,
null, null,
getLocale() getLocale()

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

@@ -100,6 +100,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/persons/{id}/relationships/{relId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateRelationship"];
post?: never;
delete: operations["deleteRelationship"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/geschichten/{id}/items/reorder": { "/api/geschichten/{id}/items/reorder": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1032,6 +1048,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;
@@ -1624,22 +1656,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/persons/{id}/relationships/{relId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteRelationship"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/aliases/{aliasId}": { "/api/persons/{id}/aliases/{aliasId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1837,6 +1853,50 @@ export interface components {
provisional: boolean; provisional: boolean;
readonly displayName: string; readonly displayName: string;
}; };
RelationshipUpsertRequest: {
/** Format: uuid */
relatedPersonId: string;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: date */
fromDate?: string;
/** @enum {string} */
fromDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
toDate?: string;
/** @enum {string} */
toDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
notes?: string;
};
RelationshipDTO: {
/** Format: uuid */
id: string;
/** Format: uuid */
personId: string;
/** Format: uuid */
relatedPersonId: string;
personDisplayName: string;
/** Format: int32 */
personBirthYear?: number;
/** Format: int32 */
personDeathYear?: number;
relatedPersonDisplayName: string;
/** Format: int32 */
relatedPersonBirthYear?: number;
/** Format: int32 */
relatedPersonDeathYear?: number;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: date */
fromDate?: string;
/** @enum {string} */
fromDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
toDate?: string;
/** @enum {string} */
toDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
notes?: string;
};
JourneyReorderDTO: { JourneyReorderDTO: {
itemIds?: string[]; itemIds?: string[];
}; };
@@ -1992,42 +2052,6 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
targetId: string; targetId: string;
}; };
CreateRelationshipRequest: {
/** Format: uuid */
relatedPersonId: string;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
RelationshipDTO: {
/** Format: uuid */
id: string;
/** Format: uuid */
personId: string;
/** Format: uuid */
relatedPersonId: string;
personDisplayName: string;
/** Format: int32 */
personBirthYear?: number;
/** Format: int32 */
personDeathYear?: number;
relatedPersonDisplayName: string;
/** Format: int32 */
relatedPersonBirthYear?: number;
/** Format: int32 */
relatedPersonDeathYear?: number;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
PersonNameAliasDTO: { PersonNameAliasDTO: {
lastName: string; lastName: string;
firstName?: string; firstName?: string;
@@ -2413,6 +2437,42 @@ 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";
/** Format: uuid */
rootTagId?: string;
rootTagName?: string;
rootTagColor?: string;
};
TimelineYearDTO: {
/** Format: int32 */
year: number;
entries: components["schemas"]["TimelineEntryDTO"][];
};
TagTreeNodeDTO: { TagTreeNodeDTO: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -2468,10 +2528,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;
@@ -3148,6 +3208,54 @@ export interface operations {
}; };
}; };
}; };
updateRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RelationshipUpsertRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RelationshipDTO"];
};
};
};
};
deleteRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
reorderItems: { reorderItems: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3611,7 +3719,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CreateRelationshipRequest"]; "application/json": components["schemas"]["RelationshipUpsertRequest"];
}; };
}; };
responses: { responses: {
@@ -4993,6 +5101,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?: {
@@ -5831,27 +5965,6 @@ export interface operations {
}; };
}; };
}; };
deleteRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeAlias: { removeAlias: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -40,4 +40,62 @@ 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'
});
});
// #833 REQ-015: the new visual-fidelity strings (meta line, provenance token,
// ✉ label, world-band suffix, density caption) are Paraglide keys present in
// every locale so no surface ever falls back to a missing translation.
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
const requiredKeys = [
'timeline_grouping_date',
'timeline_provenance_derived',
'timeline_provenance_curated',
'timeline_letter_glyph_label',
'timeline_layer_historical_suffix',
'timeline_strip_density_caption',
'timeline_events_count',
'timeline_letters_count_singular',
'timeline_events_count_singular'
];
for (const key of requiredKeys) {
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
});
// #835 REQ-013: the letter chip's sr-only theme label is a Paraglide key in every
// locale so color is never the only cue; the tag NAME is rendered as data, not translated.
it('zeitstrahl tag-chip label key is present in all locales (#835 REQ-013)', () => {
expect(de).toMatchObject({ timeline_tag_chip_label: 'Thema' });
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
});
}); });

View File

@@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-spouse', relatedPersonId: 'p-spouse',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Otto Raddatz', relatedPersonDisplayName: 'Otto Raddatz',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'r2', id: 'r2',
@@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-friend', relatedPersonId: 'p-friend',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend', relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND' relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'r3', id: 'r3',
@@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-sibling', relatedPersonId: 'p-sibling',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Marie Sister', relatedPersonDisplayName: 'Marie Sister',
relationType: 'SIBLING_OF' relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];
render(PersonHoverCard, { render(PersonHoverCard, {
@@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-aug', relatedPersonId: 'p-aug',
personDisplayName: 'Heinrich Raddatz', personDisplayName: 'Heinrich Raddatz',
relatedPersonDisplayName: 'Auguste Raddatz', relatedPersonDisplayName: 'Auguste Raddatz',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];
render(PersonHoverCard, { render(PersonHoverCard, {
@@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => {
relatedPersonId: 'p-friend', relatedPersonId: 'p-friend',
personDisplayName: 'Auguste', personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend', relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND' relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];
render(PersonHoverCard, { render(PersonHoverCard, {

View File

@@ -1,17 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte'; import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate'; import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no // Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773). // sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.person_precision_day },
{ value: 'MONTH', label: m.person_precision_month },
{ value: 'YEAR', label: m.person_precision_year }
];
let { let {
name, name,
legend, legend,
@@ -26,73 +19,21 @@ let {
initialPrecision?: string | null; initialPrecision?: string | null;
} = $props(); } = $props();
let iso = $state(''); const precisions: { value: DatePrecision; label: string }[] = $derived([
let errorMessage = $state<string | null>(null); { value: 'DAY', label: m.person_precision_day() },
let inputEl = $state<HTMLInputElement | undefined>(); { value: 'MONTH', label: m.person_precision_month() },
let precision = $state<DatePrecision>('DAY'); { value: 'YEAR', label: m.person_precision_year() }
]);
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not const hint = $derived(`${m.person_precision_hint()} · ${m.person_date_placeholder_hint()}`);
// stomp the user's in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — submitting then would silently
// clear a stored date. Block native submission until completed or fully emptied.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script> </script>
<fieldset> <DateInputWithPrecision
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"> name={name}
{legend} legend={legend}
</legend> precisionLabel={precisionLabel}
<div class="flex flex-col gap-2 sm:flex-row"> precisions={precisions}
<div class="flex-1"> hint={hint}
<DateInput initialIso={initialIso}
bind:value={iso} initialPrecision={initialPrecision}
bind:errorMessage={errorMessage} selectClass="bg-surface"
bind:inputEl={inputEl} />
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class={controlCls}
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} bg-surface"
>
{#each PERSON_DATE_PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">
{m.person_precision_hint()} · {m.person_date_placeholder_hint()}
</p>
</fieldset>

View File

@@ -7,9 +7,23 @@ type Person = components['schemas']['Person'];
interface Props { interface Props {
selectedPersons?: PersonOption[]; selectedPersons?: PersonOption[];
/** Name of the hidden inputs carrying selected ids. Mirrors DocumentMultiSelect. */
hiddenInputName?: string;
/** Empty-state text shown inside the chip container when nothing is selected. */
emptyLabel?: string;
/** id of the search input so a <label for=...> can be associated. */
inputId?: string;
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
onchange?: () => void;
} }
let { selectedPersons = $bindable([]) }: Props = $props(); let {
selectedPersons = $bindable([]),
hiddenInputName = 'receiverIds',
emptyLabel = undefined,
inputId = undefined,
onchange = undefined
}: Props = $props();
let searchTerm = $state(''); let searchTerm = $state('');
let results: Person[] = $state([]); let results: Person[] = $state([]);
@@ -54,17 +68,19 @@ function selectPerson(person: Person) {
searchTerm = ''; searchTerm = '';
showDropdown = false; showDropdown = false;
results = []; results = [];
onchange?.();
} }
function removePerson(id: string | undefined) { function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter((p) => p.id !== id); selectedPersons = selectedPersons.filter((p) => p.id !== id);
onchange?.();
} }
</script> </script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} /> <svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person (person.id)} {#each selectedPersons as person (person.id)}
<input type="hidden" name="receiverIds" value={person.id} /> <input type="hidden" name={hiddenInputName} value={person.id} />
{/each} {/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}> <div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
@@ -79,7 +95,7 @@ function removePerson(id: string | undefined) {
<button <button
type="button" type="button"
onclick={() => removePerson(person.id)} onclick={() => removePerson(person.id)}
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none" class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-label={m.comp_multiselect_remove()} aria-label={m.comp_multiselect_remove()}
> >
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -94,8 +110,13 @@ function removePerson(id: string | undefined) {
</span> </span>
{/each} {/each}
{#if emptyLabel && selectedPersons.length === 0}
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
{/if}
<input <input
bind:this={inputEl} bind:this={inputEl}
id={inputId}
type="text" type="text"
autocomplete="off" autocomplete="off"
bind:value={searchTerm} bind:value={searchTerm}

View File

@@ -258,6 +258,19 @@ describe('PersonMultiSelect removing persons', () => {
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument(); await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
}); });
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
it('renders a ≥44px touch target on the chip remove button', async () => {
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', displayName: 'Max Mustermann' }]
});
const removeBtn = (await page
.getByRole('button', { name: 'Entfernen' })
.first()
.element()) as HTMLElement;
expect(removeBtn.className).toContain('min-h-[44px]');
expect(removeBtn.className).toContain('min-w-[44px]');
});
it('removes the corresponding hidden input when a chip is removed', async () => { it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, { render(PersonMultiSelect, {
selectedPersons: [ selectedPersons: [

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte'; import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte'; import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels'; import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -29,13 +30,15 @@ let {
type RelationType = NonNullable<RelationshipDTO['relationType']>; type RelationType = NonNullable<RelationshipDTO['relationType']>;
const sortedDirect = $derived([...relationships].sort(byTypeThenYear)); const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
const topDerived = $derived(inferredRelationships.slice(0, 5)); const topDerived = $derived(inferredRelationships.slice(0, 5));
let editingRelId = $state<string | null>(null);
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number { function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number {
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType); const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
if (order !== 0) return order; if (order !== 0) return order;
return (a.fromYear ?? 0) - (b.fromYear ?? 0); // ISO dates sort lexicographically == chronologically; a missing date sorts first.
return (a.fromDate ?? '').localeCompare(b.fromDate ?? '');
} }
function relationTypeOrder(t: RelationType | undefined): number { function relationTypeOrder(t: RelationType | undefined): number {
@@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number {
return order[t ?? 'OTHER'] ?? 99; return order[t ?? 'OTHER'] ?? 99;
} }
function yearRange(rel: RelationshipDTO): string { function dateRangeOf(rel: RelationshipDTO): string {
const from = rel.fromYear; return formatRelationshipDateRange(
const to = rel.toYear; rel.fromDate,
if (from && to) return `${from}${to}`; rel.fromDatePrecision,
if (from) return m.relation_year_from({ year: from }); rel.toDate,
if (to) return m.relation_year_to({ year: to }); rel.toDatePrecision
return ''; );
} }
</script> </script>
@@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string {
<RelationshipChip <RelationshipChip
chipLabel={chipLabel(rel, personId)} chipLabel={chipLabel(rel, personId)}
otherName={otherName(rel, personId)} otherName={otherName(rel, personId)}
yearRange={yearRange(rel)} dateRange={dateRangeOf(rel)}
canWrite={canWrite} canWrite={canWrite}
relId={rel.id} relId={rel.id}
onEdit={canWrite ? () => (editingRelId = rel.id) : undefined}
/> />
{#if editingRelId === rel.id}
<li>
<AddRelationshipForm
personId={personId}
relationship={rel}
onClose={() => (editingRelId = null)}
/>
</li>
{/if}
{/each} {/each}
</ul> </ul>
{/if} {/if}

View File

@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
expect(items.length).toBeGreaterThanOrEqual(2); expect(items.length).toBeGreaterThanOrEqual(2);
}); });
it('renders the year range "fromto" for a relationship with both years', async () => { it('renders the date range "from to" for a relationship with both dates', async () => {
render(StammbaumCard, { render(StammbaumCard, {
props: baseProps({ props: baseProps({
relationships: [ relationships: [
{ {
id: 'r-1', id: 'r-1',
personId: 'p-1',
relatedPersonId: 'p-x',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Xavier',
relationType: 'COLLEAGUE', relationType: 'COLLEAGUE',
fromYear: 1940, fromDate: '1940-01-01',
toYear: 1945, fromDatePrecision: 'YEAR',
personA: { id: 'p-1', displayName: 'Anna' }, toDate: '1945-01-01',
personB: { id: 'p-x', displayName: 'Xavier' } toDatePrecision: 'YEAR'
} }
] ]
}) })
@@ -131,23 +135,27 @@ describe('StammbaumCard', () => {
expect(document.body.textContent).toContain('1945'); expect(document.body.textContent).toContain('1945');
}); });
it('renders only "fromYear" for a relationship with no end year', async () => { it('renders only the start date for a relationship with no end date', async () => {
render(StammbaumCard, { render(StammbaumCard, {
props: baseProps({ props: baseProps({
relationships: [ relationships: [
{ {
id: 'r-2', id: 'r-2',
personId: 'p-1',
relatedPersonId: 'p-y',
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Yvonne',
relationType: 'NEIGHBOR', relationType: 'NEIGHBOR',
fromYear: 1935, fromDate: '1935-01-01',
personA: { id: 'p-1', displayName: 'Anna' }, fromDatePrecision: 'YEAR',
personB: { id: 'p-y', displayName: 'Yvonne' } toDatePrecision: 'UNKNOWN'
} }
] ]
}) })
}); });
expect(document.body.textContent).toContain('1935'); expect(document.body.textContent).toContain('1935');
expect(document.body.textContent).not.toContain('1935'); expect(document.body.textContent).not.toContain('1935 ');
}); });
it('renders the inferred-relationships disclosure when topDerived has items', async () => { it('renders the inferred-relationships disclosure when topDerived has items', async () => {

View File

@@ -250,7 +250,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={bCenter.y} y2={bCenter.y}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined} stroke-dasharray={e.toDate ? '4 4' : undefined}
/> />
<circle <circle
cx={(aCenter.x + bCenter.x) / 2} cx={(aCenter.x + bCenter.x) / 2}

View File

@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
relatedPersonId: childId, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF', relationType: 'SPOUSE_OF',
toYear: 1950 fromDatePrecision: 'UNKNOWN',
toDate: '1950-01-01',
toDatePrecision: 'YEAR'
}; };
} }

View File

@@ -54,12 +54,19 @@ async function loadFor(id: string) {
} }
async function handleAddRelationship(data: RelFormData) { async function handleAddRelationship(data: RelFormData) {
const body: Record<string, string | number> = { const body: Record<string, string> = {
relatedPersonId: data.relatedPersonId, relatedPersonId: data.relatedPersonId,
relationType: data.relationType relationType: data.relationType
}; };
if (data.fromYear !== undefined) body.fromYear = data.fromYear; if (data.fromDate) {
if (data.toYear !== undefined) body.toYear = data.toYear; body.fromDate = data.fromDate;
if (data.fromDatePrecision) body.fromDatePrecision = data.fromDatePrecision;
}
if (data.toDate) {
body.toDate = data.toDate;
if (data.toDatePrecision) body.toDatePrecision = data.toDatePrecision;
}
if (data.notes) body.notes = data.notes;
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, { const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => {
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
}); });
it('year inputs inside the add form have label elements (canWrite=true)', async () => { it('date inputs inside the add form have accessible labels (canWrite=true)', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true }); render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) => const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
/Beziehung hinzufügen/i.test(b.textContent ?? '') /Beziehung hinzufügen/i.test(b.textContent ?? '')
); );
addBtn!.click(); addBtn!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const yearInputs = [...document.querySelectorAll('input')].filter( const dateInputs = [...document.querySelectorAll('input')].filter(
(i) => i.inputMode === 'numeric' (i) => i.inputMode === 'numeric'
); );
expect(yearInputs.length).toBeGreaterThan(0); expect(dateInputs.length).toBeGreaterThan(0);
for (const input of yearInputs) { for (const input of dateInputs) {
expect(input.closest('label')).not.toBeNull(); expect(input.getAttribute('aria-label')).toBeTruthy();
} }
}); });

View File

@@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte'; import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom'; import type { PanZoomState } from './panZoom';
import { DIMMED_OPACITY } from './layout/highlightLineage'; import { DIMMED_OPACITY } from './layout/highlightLineage';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const ID_A = '00000000-0000-0000-0000-000000000001'; const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002'; const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B, relatedPersonId: PARENT_B,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1a', id: 'p1a',
@@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1, relatedPersonId: CHILD_1,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1b', id: 'p1b',
@@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_1, relatedPersonId: CHILD_1,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2a', id: 'p2a',
@@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2, relatedPersonId: CHILD_2,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2b', id: 'p2b',
@@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD_2, relatedPersonId: CHILD_2,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: PARENT_B, relatedPersonId: PARENT_B,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: EUGENIE, relatedPersonId: EUGENIE,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p3', id: 'p3',
@@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p4', id: 'p4',
@@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 's2', id: 's2',
@@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: HILDE, relatedPersonId: HILDE,
personDisplayName: 'Hans', personDisplayName: 'Hans',
relatedPersonDisplayName: 'Hilde', relatedPersonDisplayName: 'Hilde',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p5', id: 'p5',
@@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI, relatedPersonId: LILI,
personDisplayName: 'Hans', personDisplayName: 'Hans',
relatedPersonDisplayName: 'Lili', relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p6', id: 'p6',
@@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: LILI, relatedPersonId: LILI,
personDisplayName: 'Hilde', personDisplayName: 'Hilde',
relatedPersonDisplayName: 'Lili', relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF', relationType: 'SPOUSE_OF',
toYear: 1925 fromDatePrecision: 'UNKNOWN',
toDate: '1925-01-01',
toDatePrecision: 'YEAR'
} }
], ],
selectedId: null, selectedId: null,
@@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: ID_B, relatedPersonId: ID_B,
personDisplayName: 'Anna', personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha', relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
relatedPersonId: CHILD, relatedPersonId: CHILD,
personDisplayName: 'Parent', personDisplayName: 'Parent',
relatedPersonDisplayName: 'Child', relatedPersonDisplayName: 'Child',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
], ],
selectedId: null, selectedId: null,
@@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => {
personDisplayName: string; personDisplayName: string;
relatedPersonDisplayName: string; relatedPersonDisplayName: string;
relationType: 'PARENT_OF' | 'SPOUSE_OF'; relationType: 'PARENT_OF' | 'SPOUSE_OF';
fromDatePrecision: 'UNKNOWN';
toDatePrecision: 'UNKNOWN';
}; };
const edge = ( const edge = (
personId: string, personId: string,
@@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => {
relatedPersonId, relatedPersonId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}); });
const NODES = [ const NODES = [
@@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
// year, then a deterministic id tie-break), not alphabetically — with no birth // year, then a deterministic id tie-break), not alphabetically — with no birth
// years here Walter (id …a1) owns the run and Eugenie sits to his right. So the // years here Walter (id …a1) owns the run and Eugenie sits to his right. So the
// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans. // deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
const FAMILY_EDGES = [ const FAMILY_EDGES: RelationshipDTO[] = [
{ {
id: 'sp', id: 'sp',
personId: WALTER, personId: WALTER,
relatedPersonId: EUGENIE, relatedPersonId: EUGENIE,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p1', id: 'p1',
@@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p2', id: 'p2',
@@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: CLARA, relatedPersonId: CLARA,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara', relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p3', id: 'p3',
@@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}, },
{ {
id: 'p4', id: 'p4',
@@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
relatedPersonId: HANS, relatedPersonId: HANS,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
} }
]; ];

View File

@@ -42,7 +42,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
relatedPersonId: childId, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -53,7 +55,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
relatedPersonId: b, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
fromYear: number | undefined, fromYear: number | undefined,
id = a + b id = a + b
): RelationshipDTO { ): RelationshipDTO {
return { ...spouseEdge(a, b, id), fromYear }; return {
...spouseEdge(a, b, id),
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
};
} }
it('multi_spouses_ordered_by_fromYear_then_displayName', () => { it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
@@ -329,7 +336,7 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
// fail fast instead so the maintainer either updates the test or // fail fast instead so the maintainer either updates the test or
// splits into a year-branch / name-branch pair. // splits into a year-branch / name-branch pair.
const spouseEdgesWithYear = fixtureEdges.filter( const spouseEdgesWithYear = fixtureEdges.filter(
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null (e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
); );
expect( expect(
spouseEdgesWithYear, spouseEdgesWithYear,

View File

@@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO {
relatedPersonId: c, relatedPersonId: c,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -33,7 +35,9 @@ function spouse(a: string, b: string, fromYear?: number): RelationshipDTO {
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF', relationType: 'SPOUSE_OF',
...(fromYear != null ? { fromYear } : {}) fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
}; };
} }

View File

@@ -82,7 +82,10 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO
} else if (e.relationType === 'SPOUSE_OF') { } else if (e.relationType === 'SPOUSE_OF') {
addToSet(spouses, e.personId, e.relatedPersonId); addToSet(spouses, e.personId, e.relatedPersonId);
addToSet(spouses, e.relatedPersonId, e.personId); addToSet(spouses, e.relatedPersonId, e.personId);
spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined); spouseYear.set(
pairKey(e.personId, e.relatedPersonId),
e.fromDate ? Number(e.fromDate.slice(0, 4)) : undefined
);
} }
} }

View File

@@ -13,7 +13,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
relatedPersonId: childId, relatedPersonId: childId,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'PARENT_OF' relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -24,7 +26,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
relatedPersonId: b, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }
@@ -35,7 +39,9 @@ function siblingEdge(a: string, b: string, id = a + b): RelationshipDTO {
relatedPersonId: b, relatedPersonId: b,
personDisplayName: '', personDisplayName: '',
relatedPersonDisplayName: '', relatedPersonDisplayName: '',
relationType: 'SIBLING_OF' relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}; };
} }

View File

@@ -1,23 +1,17 @@
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
/** /**
* Formats one life date (birth or death) at the precision the data claims, * Formats one life date (birth or death) at the precision the data claims.
* delegating all rendering to {@link formatDocumentDate}. Returns '' for a * Thin domain alias over the shared {@link formatDatePart}: carries no * / †
* missing date. Carries no * / † glyph — components that need the glyphs wrap * glyph — components that need the glyphs wrap them in their own `aria-hidden`
* them in their own `aria-hidden` markup so screen readers only hear the date. * markup so screen readers only hear the date.
*
* A missing precision falls back to YEAR: pre-V76 rows only knew a year, and
* a bare year is the only safe rendering for a date without precision metadata.
*/ */
export function formatLifeDate( export function formatLifeDate(
date: string | null | undefined, date: string | null | undefined,
precision: DatePrecision | null | undefined, precision: DatePrecision | null | undefined,
locale?: string locale?: string
): string { ): string {
if (!date) { return formatDatePart(date, precision, locale);
return '';
}
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
} }
/** /**

View File

@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>; type RelationType = NonNullable<RelationshipDTO['relationType']>;
@@ -10,71 +13,96 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = { export type RelFormData = {
relatedPersonId: string; relatedPersonId: string;
relationType: RelationType; relationType: RelationType;
fromYear?: number; fromDate?: string;
toYear?: number; fromDatePrecision?: DatePrecision;
toDate?: string;
toDatePrecision?: DatePrecision;
notes?: string;
}; };
interface Props { interface Props {
personId: string; personId: string;
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
relationship?: RelationshipDTO;
onSubmit?: (data: RelFormData) => Promise<void>; onSubmit?: (data: RelFormData) => Promise<void>;
onClose?: () => void;
} }
let { personId, onSubmit }: Props = $props(); let { personId, relationship, onSubmit, onClose }: Props = $props();
const isEdit = $derived(relationship != null);
let open = $state(false); let open = $state(false);
let addType = $state<RelationType>('PARENT_OF'); let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state(''); let addRelatedPersonId = $state('');
let addRelatedPersonName = $state(''); let addRelatedPersonName = $state('');
let addFromYear = $state(''); let notes = $state('');
let addToYear = $state('');
let callbackError = $state<string | null>(null); let callbackError = $state<string | null>(null);
let submitting = $state(false);
const yearError = $derived.by(() => { // Seed once at mount (reading props in a closure avoids state_referenced_locally).
const from = addFromYear.trim(); // The parent re-creates this form per edited row, so the relationship never
const to = addToYear.trim(); // changes under a live instance.
if (!from || !to) return null; onMount(() => {
const fromInt = parseInt(from, 10); if (!relationship) return;
const toInt = parseInt(to, 10); open = true;
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null; addType = relationship.relationType ?? 'PARENT_OF';
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null; const viewpointIsSubject = relationship.personId === personId;
addRelatedPersonId =
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
addRelatedPersonName =
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
'';
notes = relationship.notes ?? '';
}); });
const selfError = $derived( const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
); );
const submitDisabled = $derived( const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
function reset() { function reset() {
addType = 'PARENT_OF'; addType = 'PARENT_OF';
addRelatedPersonId = ''; addRelatedPersonId = '';
addRelatedPersonName = ''; addRelatedPersonName = '';
addFromYear = ''; notes = '';
addToYear = '';
callbackError = null; callbackError = null;
} }
function cancel() { function cancel() {
if (isEdit) {
onClose?.();
return;
}
open = false; open = false;
reset(); reset();
} }
async function handleCallbackSubmit(event: Event) { async function handleCallbackSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (submitDisabled || !onSubmit) return; if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType }; const fd = new FormData(event.currentTarget as HTMLFormElement);
const from = parseInt(addFromYear.trim(), 10); const fromDate = (fd.get('fromDate') as string) || undefined;
if (!Number.isNaN(from)) data.fromYear = from; const toDate = (fd.get('toDate') as string) || undefined;
const to = parseInt(addToYear.trim(), 10); const data: RelFormData = {
if (!Number.isNaN(to)) data.toYear = to; relatedPersonId: addRelatedPersonId,
relationType: addType,
fromDate,
fromDatePrecision: fromDate ? (fd.get('fromDatePrecision') as DatePrecision) : undefined,
toDate,
toDatePrecision: toDate ? (fd.get('toDatePrecision') as DatePrecision) : undefined,
notes: (fd.get('notes') as string)?.trim() || undefined
};
submitting = true;
try { try {
await onSubmit(data); await onSubmit(data);
open = false; open = false;
reset(); reset();
} catch { } catch {
callbackError = m.error_internal_error(); callbackError = m.error_internal_error();
} finally {
submitting = false;
} }
} }
</script> </script>
@@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) {
compact compact
/> />
</div> </div>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2"
>{m.relation_form_field_from_year()}</span
>
<input
type="text"
name="fromYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addFromYear}
placeholder={m.relation_form_year_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
>
<input
type="text"
name="toYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addToYear}
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
{#if yearError}
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
{yearError}
</p>
{/if}
</label>
</div> </div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<RelationshipDateField
name="fromDate"
legend={m.relation_label_from_date()}
initialIso={relationship?.fromDate ?? ''}
initialPrecision={relationship?.fromDatePrecision ?? null}
/>
<RelationshipDateField
name="toDate"
legend={m.relation_label_to_date()}
initialIso={relationship?.toDate ?? ''}
initialPrecision={relationship?.toDatePrecision ?? null}
/>
</div>
<label class="mt-3 block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_notes()}</span>
<textarea
name="notes"
maxlength="2000"
rows="2"
bind:value={notes}
placeholder={m.relation_notes_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 font-serif text-sm text-ink-3 focus:border-primary focus:outline-none"
></textarea>
</label>
{#if selfError} {#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p> <p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if} {/if}
@@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) {
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitDisabled} disabled={submitDisabled || submitting}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40" aria-busy={submitting}
class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
> >
{m.relation_btn_add()} {#if submitting}
<span
class="h-3 w-3 animate-spin rounded-full border-2 border-primary-fg/40 border-t-primary-fg"
data-testid="submit-spinner"
aria-hidden="true"
></span>
{/if}
{isEdit ? m.relation_btn_save() : m.relation_btn_add()}
</button> </button>
</div> </div>
{/snippet} {/snippet}
@@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) {
{:else} {:else}
<form <form
method="POST" method="POST"
action="?/addRelationship" action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
use:enhance={() => { use:enhance={() => {
submitting = true;
return async ({ result, update }) => { return async ({ result, update }) => {
await update(); await update();
submitting = false;
if (result.type === 'success') { if (result.type === 'success') {
open = false; if (isEdit) {
reset(); onClose?.();
} else {
open = false;
reset();
}
} }
}; };
}} }}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3" class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
> >
{#if relationship}
<input type="hidden" name="relId" value={relationship.id} />
{/if}
{@render formFields()} {@render formFields()}
</form> </form>
{/if} {/if}

View File

@@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup); afterEach(cleanup);
describe('AddRelationshipForm', () => { const PID = 'person-1';
it('shows add-relationship button initially and no form', async () => { const OTHER = 'person-2';
render(AddRelationshipForm, { personId: 'person-1' });
const editRel = () => ({
id: 'rel-9',
personId: PID,
relatedPersonId: OTHER,
personDisplayName: 'Anna Müller',
relatedPersonDisplayName: 'Hans Müller',
relationType: 'SPOUSE_OF' as const,
fromDate: '1923-05-12',
fromDatePrecision: 'DAY' as const,
toDatePrecision: 'UNKNOWN' as const,
notes: 'Hochzeit in Berlin'
});
describe('AddRelationshipForm — create mode', () => {
it('shows the add-relationship toggle initially and no form', async () => {
render(AddRelationshipForm, { personId: PID });
await expect.element(page.getByRole('button')).toBeInTheDocument(); await expect.element(page.getByRole('button')).toBeInTheDocument();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); expect(document.querySelector('select[name="relationType"]')).toBeNull();
}); });
it('shows relationType select when add button is clicked', async () => { it('shows the relationType select when the add toggle is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
}); });
it('hides form and shows button when cancel is clicked', async () => { it('hides the form and shows the toggle again on cancel', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find( const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '') (b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
); );
cancelBtn!.click(); cancelBtn!.click();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); await vi.waitFor(() =>
expect(document.querySelector('select[name="relationType"]')).toBeNull()
);
}); });
it('submit is disabled when no person is selected', async () => { it('disables submit when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' }); render(AddRelationshipForm, { personId: PID });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled(); await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
}); });
it('form has no server action when onSubmit prop is provided', async () => { it('has no server action when an onSubmit prop is provided', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined); const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: 'person-1', onSubmit }); render(AddRelationshipForm, { personId: PID, onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click(); document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const form = document.querySelector('form'); expect(document.querySelector('form')?.hasAttribute('action')).toBe(false);
expect(form?.hasAttribute('action')).toBe(false); });
}); });
it('shows year-range error when toYear is before fromYear', async () => { describe('AddRelationshipForm — edit mode', () => {
render(AddRelationshipForm, { personId: 'person-1' }); it('opens pre-filled and labels the submit "Speichern"', async () => {
document.querySelector<HTMLButtonElement>('button')!.click(); render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument();
});
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935'; it('pre-fills the from-date as dd.mm.yyyy', async () => {
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true })); render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!; const fromInput = document.querySelector<HTMLInputElement>('#fromDate')!;
toInput.value = '1920'; await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923'));
toInput.dispatchEvent(new InputEvent('input', { bubbles: true })); });
await expect.element(page.getByRole('alert')).toBeVisible(); it('round-trips the notes into the textarea', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const notes = document.querySelector<HTMLTextAreaElement>('textarea[name="notes"]')!;
await vi.waitFor(() => expect(notes.value).toBe('Hochzeit in Berlin'));
});
it('offers only DAY/MONTH/YEAR in each precision select', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const options = [
...document.querySelectorAll<HTMLOptionElement>('#fromDatePrecision option')
].map((o) => o.value);
expect(options).toEqual(['DAY', 'MONTH', 'YEAR']);
});
it('gives each date input an associated label (accessible name)', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
expect(document.querySelector('#fromDate')?.getAttribute('aria-label')).toBe('Beginn (Datum)');
expect(document.querySelector('#toDate')?.getAttribute('aria-label')).toBe('Ende (Datum)');
});
it('disables the submit and shows a progress spinner while a submit is in flight', async () => {
let resolve: () => void = () => {};
const onSubmit = vi.fn(() => new Promise<void>((r) => (resolve = r)));
render(AddRelationshipForm, { personId: PID, relationship: editRel(), onSubmit });
const submit = await vi.waitFor(() => {
const b = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(x) => x.type === 'submit'
);
if (!b) throw new Error('submit not ready');
return b;
});
submit.click();
await expect.element(page.getByTestId('submit-spinner')).toBeInTheDocument();
await vi.waitFor(() => expect(submit.disabled).toBe(true));
expect(onSubmit).toHaveBeenCalledOnce();
resolve();
}); });
}); });

View File

@@ -33,36 +33,6 @@ describe('AddRelationshipForm', () => {
expect(optionValues).toContain('OTHER'); expect(optionValues).toContain('OTHER');
}); });
it('shows the year-error alert when toYear is before fromYear', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1920';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/bis-jahr darf nicht vor von-jahr/i)).toBeVisible();
});
it('does not show the year-error when toYear equals fromYear', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1923';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/bis-jahr darf nicht/i)).not.toBeInTheDocument();
});
it('cancel button closes the form', async () => { it('cancel button closes the form', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } }); render(AddRelationshipForm, { props: { personId: 'p-1' } });
@@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => {
expect(submitBtn!.disabled).toBe(true); expect(submitBtn!.disabled).toBe(true);
}); });
}); });
it('keeps submit disabled when there is a yearError', async () => {
render(AddRelationshipForm, { props: { personId: 'p-1' } });
await page.getByRole('button', { name: /hinzufügen/i }).click();
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
const relInput = document.querySelector('input[name="relatedPersonId"]') as HTMLInputElement;
fromInput.value = '1923';
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.value = '1920';
toInput.dispatchEvent(new Event('input', { bubbles: true }));
relInput.value = 'p-other';
relInput.dispatchEvent(new Event('input', { bubbles: true }));
await vi.waitFor(() => {
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(submitBtn.disabled).toBe(true);
});
});
}); });

View File

@@ -5,12 +5,13 @@ import { m } from '$lib/paraglide/messages.js';
interface Props { interface Props {
chipLabel: string; chipLabel: string;
otherName: string; otherName: string;
yearRange?: string; dateRange?: string;
canWrite: boolean; canWrite: boolean;
relId: string; relId: string;
onEdit?: () => void;
} }
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props(); let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
</script> </script>
<li class="flex items-center gap-2 py-2"> <li class="flex items-center gap-2 py-2">
@@ -22,8 +23,31 @@ let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink"> <span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName} {otherName}
</span> </span>
{#if yearRange} {#if dateRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span> <span class="shrink-0 font-sans text-xs text-ink-3" data-testid="date-range">{dateRange}</span>
{/if}
{#if canWrite && onEdit}
<button
type="button"
onclick={onEdit}
aria-label="{m.relation_edit()} {otherName}"
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-primary"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
/>
</svg>
</button>
{/if} {/if}
{#if canWrite} {#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0"> <form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">

View File

@@ -10,7 +10,7 @@ afterEach(cleanup);
const baseProps = { const baseProps = {
chipLabel: 'Elternteil', chipLabel: 'Elternteil',
otherName: 'Anna Schmidt', otherName: 'Anna Schmidt',
yearRange: '', dateRange: '',
canWrite: false, canWrite: false,
relId: 'rel-1' relId: 'rel-1'
}; };
@@ -26,30 +26,55 @@ describe('RelationshipChip', () => {
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
}); });
it('shows year range when provided', async () => { it('shows the date range when provided', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '19201980' }); render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 1958' });
await expect.element(page.getByText('19201980')).toBeInTheDocument(); await expect.element(page.getByText('12. Mai 1923 1958')).toBeInTheDocument();
}); });
it('does not show year range span when empty', async () => { it('does not render a date-range span when empty', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '' }); render(RelationshipChip, { ...baseProps, dateRange: '' });
expect(document.querySelector('[data-testid="year-range"]')).toBeNull(); expect(document.querySelector('[data-testid="date-range"]')).toBeNull();
}); });
it('shows delete button when canWrite is true', async () => { it('shows the delete button when canWrite is true', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true }); render(RelationshipChip, { ...baseProps, canWrite: true });
await expect.element(page.getByRole('button')).toBeInTheDocument(); await expect.element(page.getByRole('button')).toBeInTheDocument();
}); });
it('hides delete button when canWrite is false', async () => { it('hides the delete button when canWrite is false', async () => {
render(RelationshipChip, { ...baseProps, canWrite: false }); render(RelationshipChip, { ...baseProps, canWrite: false });
expect(document.querySelector('button')).toBeNull(); expect(document.querySelector('button')).toBeNull();
}); });
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => { it('gives the delete button an h-11 w-11 (44px) WCAG touch target', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true }); render(RelationshipChip, { ...baseProps, canWrite: true });
const btn = document.querySelector('button')!; const btn = document.querySelector('button')!;
expect(btn.className).toContain('h-11'); expect(btn.className).toContain('h-11');
expect(btn.className).toContain('w-11'); expect(btn.className).toContain('w-11');
}); });
it('shows an Edit affordance with an accessible name when canWrite and onEdit', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true, onEdit: () => {} });
await expect
.element(page.getByRole('button', { name: /Beziehung bearbeiten/i }))
.toBeInTheDocument();
});
it('does not show the Edit affordance without onEdit', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull();
});
it('does not show the Edit affordance when canWrite is false', async () => {
render(RelationshipChip, { ...baseProps, canWrite: false, onEdit: () => {} });
expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull();
});
it('calls onEdit when the Edit affordance is clicked', async () => {
const onEdit = vi.fn();
render(RelationshipChip, { ...baseProps, canWrite: true, onEdit });
const editBtn = document.querySelector<HTMLButtonElement>('button[aria-label*="bearbeiten"]')!;
editBtn.click();
expect(onEdit).toHaveBeenCalledOnce();
});
}); });

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered (same as the person life-date field and the
// 60+ author audience). Storage still accepts all seven precisions; SEASON/RANGE/
// APPROX render correctly elsewhere but make no sense to enter for a relationship.
let {
name,
legend,
initialIso = '',
initialPrecision = null
}: {
name: string;
legend: string;
initialIso?: string | null;
initialPrecision?: string | null;
} = $props();
const precisions: { value: DatePrecision; label: string }[] = $derived([
{ value: 'DAY', label: m.relation_precision_day() },
{ value: 'MONTH', label: m.relation_precision_month() },
{ value: 'YEAR', label: m.relation_precision_year() }
]);
</script>
<DateInputWithPrecision
name={name}
legend={legend}
precisionLabel={m.relation_label_date_precision()}
precisions={precisions}
hint={m.relation_date_placeholder_hint()}
initialIso={initialIso}
initialPrecision={initialPrecision}
inputClass="bg-surface"
selectClass="bg-surface text-ink-3"
/>

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { formatRelationshipDateRange } from './relationshipDates';
// Delegates all precision rendering to formatDocumentDate — these tests pin the
// composition (dash, single sides, empty state) and one rendering per precision,
// plus en/es for DAY/MONTH so a German-month leak is caught here, not on a card.
describe('formatRelationshipDateRange', () => {
describe('both dates (de default)', () => {
it('renders DAY precision as full dates', () => {
expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-06-13', 'DAY')).toBe(
'12. Mai 1923 13. Juni 1958'
);
});
it('renders MONTH precision as month + year', () => {
expect(formatRelationshipDateRange('1923-05-01', 'MONTH', '1958-06-01', 'MONTH')).toBe(
'Mai 1923 Juni 1958'
);
});
it('renders YEAR precision as bare years', () => {
expect(formatRelationshipDateRange('1923-01-01', 'YEAR', '1958-01-01', 'YEAR')).toBe(
'1923 1958'
);
});
it('renders mixed precisions per side', () => {
expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-01-01', 'YEAR')).toBe(
'12. Mai 1923 1958'
);
});
});
describe('single sides and empty states', () => {
it('renders from only without a trailing dash', () => {
expect(formatRelationshipDateRange('1923-05-12', 'DAY', null, null)).toBe('12. Mai 1923');
});
it('renders to only with a leading dash', () => {
expect(formatRelationshipDateRange(null, null, '1958-06-13', 'DAY')).toBe(' 13. Juni 1958');
});
it('renders nothing when both dates are missing (UNKNOWN)', () => {
expect(formatRelationshipDateRange(null, 'UNKNOWN', null, 'UNKNOWN')).toBe('');
});
it('renders nothing for a from-only with a null date', () => {
expect(formatRelationshipDateRange(null, null, null, null)).toBe('');
});
});
describe('localized months (catch German-month leak)', () => {
it('renders DAY in English with no German month name', () => {
const out = formatRelationshipDateRange('1923-05-12', 'DAY', null, null, 'en');
expect(out).toContain('May');
expect(out).not.toContain('Mai');
expect(out).toContain('1923');
});
it('renders MONTH in Spanish', () => {
const out = formatRelationshipDateRange('1923-05-01', 'MONTH', null, null, 'es');
expect(out.toLowerCase()).toContain('mayo');
});
});
});

View File

@@ -0,0 +1,30 @@
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Formats a relationship's startend range as plain text, e.g. for a marriage row.
* Examples (de):
* 12. Mai 1923 13. Juni 1958 (both)
* 12. Mai 1923 (start only — no trailing dash)
* 13. Juni 1958 (end only)
* "" (neither — the caller renders no date line)
*/
export function formatRelationshipDateRange(
fromDate: string | null | undefined,
fromDatePrecision: DatePrecision | null | undefined,
toDate: string | null | undefined,
toDatePrecision: DatePrecision | null | undefined,
locale?: string
): string {
const from = formatDatePart(fromDate, fromDatePrecision, locale);
const to = formatDatePart(toDate, toDatePrecision, locale);
if (from && to) {
return `${from} ${to}`;
}
if (from) {
return from;
}
if (to) {
return ` ${to}`;
}
return '';
}

View File

@@ -19,6 +19,8 @@ function makeRel(
personDisplayName: 'Alice', personDisplayName: 'Alice',
relatedPersonDisplayName: 'Bob', relatedPersonDisplayName: 'Bob',
relationType, relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
...override ...override
}; };
} }

Some files were not shown because too many files have changed in this diff Show More