Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel
33c6035199 docs(adr): renumber SDD adoption ADR 041 -> 042 (collision with renovate ADR)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 4m35s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 24s
Two ADR-041 files landed on main in parallel (sdd-adoption and
renovate-runner-setup). Renames the SDD one to 042 and repoints its references
(SPEC_DRIVEN_DEVELOPMENT, constitution, .specify/adrs/README, sdd-gate.yml).
The renovate ADR keeps 041; its references are left untouched. Riding this PR
per request.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:37:38 +02:00
169 changed files with 1208 additions and 10378 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,143 +43,3 @@
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
| REQ-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 |
| REQ-001 | TimelineFilters is presentation-only (3 $bindable layer booleans + onChange); no goto/url.searchParams/api.GET/fetch | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#renders the three layer toggles with accessible names`, `#reflects a layer as pressed and flips it, firing onChange`; `timelineFilterBoundary.spec.ts` | Done |
| REQ-002 | route derives a client-side $derived filtered view, passes it to TimelineView; no goto/fetch on toggle | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#hides letter cards when the Letters layer is off ... with no fetch`; `timelineFilterBoundary.spec.ts` | Done |
| REQ-003 | Personal off → personal events (curated + derived life-events) hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides personal events — curated and derived`, `page.svelte.spec.ts#hides personal event cards` | Done |
| REQ-004 | Historical off → historical event entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides HISTORICAL events`, `page.svelte.spec.ts#hides historical event cards` | Done |
| REQ-005 | Letters off → letter entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides LETTER entries`, `page.svelte.spec.ts#hides letter cards` | Done |
| REQ-006 | zero visible → filter empty-state + one-click reset below the open bar (never blank, never generic empty) | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#shows the filtered-empty message + reset below the open bar`, `timelineFilter.spec.ts#drops year bands that become empty` | Done |
| REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done |
| REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done |
| REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done |
| REQ-010 | 8 timeline_filter_* keys in de/en/es; trigger vs trigger_active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |

View File

@@ -99,7 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
├── 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
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ User domain — AppUser, UserGroup, UserService
```
@@ -121,7 +121,6 @@ backend/src/main/java/org/raddatz/familienarchiv/
| `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` |
| `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`
@@ -170,7 +169,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ 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); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), 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), plus a generic `CONFLICT` (409 optimistic-lock backstop).
### Security / Permissions
@@ -207,8 +206,6 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new
├── 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
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management
@@ -280,7 +277,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ 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); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), 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), plus a generic `CONFLICT` (409 optimistic-lock backstop).
---

View File

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

View File

@@ -1,42 +0,0 @@
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,11 +56,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
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
@EntityGraph("Document.full")
List<Document> findBySenderId(UUID senderId);

View File

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

View File

@@ -122,8 +122,6 @@ public enum ErrorCode {
CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP,
/** A relationship's toDate is before its fromDate. 400 */
INVALID_RELATIONSHIP_DATES,
// --- Geschichten (Stories) ---
/** 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.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
import org.springframework.stereotype.Component;
import java.io.File;
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
try {
relationshipService.addRelationship(person,
new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null));
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
return true;
} catch (DomainException e) {
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP

View File

@@ -242,7 +242,4 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
)
""", nativeQuery = true)
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,7 +18,6 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
@@ -211,10 +210,6 @@ public class PersonService {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
public List<Person> getPersonsByGeneration(Integer generation) {
return personRepository.findByGeneration(generation);
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId);
@@ -449,28 +444,41 @@ public class PersonService {
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthDate(dto.getBirthDate())
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate())
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
.generation(dto.getGeneration())
.build();
return personRepository.save(person);
}
// 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. Coherence
// is shared with the relationship domain (DatePrecisionValidation); only the order check
// (and its BIRTH_AFTER_DEATH code) is life-date specific.
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"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
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
@@ -487,9 +495,9 @@ public class PersonService {
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.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
// Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration());

View File

@@ -5,11 +5,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.Person;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@@ -41,25 +39,11 @@ public class PersonRelationship {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private RelationType relationType;
// Start/end of the relationship (wedding, employment start, …). The date column
// 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 = "from_year")
private Integer fromYear;
@Enumerated(EnumType.STRING)
@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(name = "to_year")
private Integer toYear;
@Column(length = 2000)
private String notes;

View File

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

View File

@@ -1,12 +1,10 @@
package org.raddatz.familienarchiv.person.relationship;
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.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
@@ -88,139 +86,66 @@ public class RelationshipService {
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
public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
requireNotSelf(personId, dto.relatedPersonId());
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId());
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
validateYears(dto.fromYear(), dto.toYear());
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()
.person(person)
.relatedPerson(relatedPerson)
.relationType(dto.relationType())
.fromDate(dto.fromDate())
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
.toDate(dto.toDate())
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
.fromYear(dto.fromYear())
.toYear(dto.toYear())
.notes(blankToNull(dto.notes()))
.build();
PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
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) {
PersonRelationship saved;
try {
return relationshipRepository.saveAndFlush(rel);
// 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 (" + subjectId + ", " + objectId + ", " + type + ")");
"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);
}
// 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);
}
return toDTO(saved);
}
@Transactional
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)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
throw DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND,
throw DomainException.forbidden(
"Relationship " + relId + " does not belong to person " + personId);
}
return rel;
relationshipRepository.delete(rel);
}
@Transactional
@@ -239,17 +164,10 @@ public class RelationshipService {
return date != null ? date.getYear() : null;
}
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
// user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
// Coherence is shared with the person domain (DatePrecisionValidation); only the order
// 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);
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
}
}
@@ -267,10 +185,8 @@ public class RelationshipService {
yearOf(rp.getBirthDate()),
yearOf(rp.getDeathDate()),
r.getRelationType(),
r.getFromDate(),
r.getFromDatePrecision(),
r.getToDate(),
r.getToDatePrecision(),
r.getFromYear(),
r.getToYear(),
r.getNotes());
}
}

View File

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

View File

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

@@ -1,13 +0,0 @@
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,59 +175,6 @@ 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.
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.

View File

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

View File

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

View File

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

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

@@ -1,52 +0,0 @@
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,8 +10,6 @@ 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.person.relationship.PersonRelationship;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
@@ -42,7 +40,6 @@ public class TimelineEventService {
private final TimelineEventRepository events;
private final PersonService personService;
private final DocumentService documentService;
private final RelationshipService relationshipService;
@Transactional
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
@@ -232,84 +229,6 @@ public class TimelineEventService {
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) ---
private TimelineEventView toView(TimelineEvent event) {

View File

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

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

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

@@ -1,43 +0,0 @@
-- 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,17 +2943,4 @@ class DocumentServiceTest {
assertThat(result.buckets()).isEmpty();
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,7 +6,6 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
@@ -170,7 +169,7 @@ class CanonicalImportOrchestratorTest {
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), parentId, childId,
"Parent", null, null, "Child", null, null,
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
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.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
new PersonTreeImporter(personService, relationshipService)
.load(json.toFile());
ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
verify(relationshipService).addRelationship(eq(idA), captor.capture());
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);

View File

@@ -1105,25 +1105,4 @@ class PersonServiceTest {
assertThat(result.direct()).hasSize(1);
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,7 +1,6 @@
package org.raddatz.familienarchiv.person.relationship;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
@@ -26,8 +25,6 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
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.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -101,7 +98,7 @@ class RelationshipControllerTest {
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980,
"Bob Müller", 1930, null,
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
@@ -142,7 +139,7 @@ class RelationshipControllerTest {
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", null, null,
"Bob Müller", null, null,
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
RelationType.PARENT_OF, null, null, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
@@ -161,51 +158,4 @@ class RelationshipControllerTest {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
.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

@@ -1,306 +0,0 @@
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,11 +4,10 @@ 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.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
@@ -21,7 +20,6 @@ import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@@ -67,17 +65,13 @@ class RelationshipServiceIntegrationTest {
@Test
void addRelationship_stores_and_is_readable() {
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.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());
assertThat(rels).hasSize(1);
@@ -86,7 +80,7 @@ class RelationshipServiceIntegrationTest {
@Test
void addRelationship_throws_409_when_duplicate() {
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
@@ -99,9 +93,9 @@ class RelationshipServiceIntegrationTest {
void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
@@ -109,58 +103,28 @@ class RelationshipServiceIntegrationTest {
}
@Test
void deleteRelationship_throws_404_when_rel_belongs_to_different_person() {
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
// 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).
// Charlie is unrelated to this row.
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
.isEqualTo(ErrorCode.FORBIDDEN);
// The row is still there.
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
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// 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.
relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
@@ -171,7 +135,7 @@ class RelationshipServiceIntegrationTest {
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
relationshipService.deleteRelationship(bob.getId(), created.id());
@@ -184,7 +148,7 @@ class RelationshipServiceIntegrationTest {
// 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.
relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null));
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
relationshipService.setFamilyMember(charlie.getId(), false);
NetworkDTO before = relationshipService.getFamilyNetwork();
@@ -201,7 +165,7 @@ class RelationshipServiceIntegrationTest {
@Test
void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent();

View File

@@ -6,18 +6,16 @@ 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.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -61,9 +59,9 @@ class RelationshipServiceTest {
charlie = person("Charlie");
}
// --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
// --- Nora blocker 1 ---
@Test
void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
@@ -71,7 +69,7 @@ class RelationshipServiceTest {
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
.isEqualTo(ErrorCode.FORBIDDEN);
verify(relationshipRepository, never()).delete(any());
}
@@ -84,7 +82,7 @@ class RelationshipServiceTest {
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
@@ -100,7 +98,7 @@ class RelationshipServiceTest {
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
@@ -109,7 +107,7 @@ class RelationshipServiceTest {
@Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
@@ -118,42 +116,14 @@ class RelationshipServiceTest {
}
@Test
void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
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.YEAR,
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.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);
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@@ -170,16 +140,13 @@ class RelationshipServiceTest {
return r;
});
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.fromYear()).isEqualTo(1900);
assertThat(result.notes()).isEqualTo("first born");
}
@@ -199,7 +166,7 @@ class RelationshipServiceTest {
return r;
});
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
service.addRelationship(alice.getId(), dto);
verify(personService).setFamilyMember(alice.getId(), true);
@@ -220,7 +187,7 @@ class RelationshipServiceTest {
return r;
});
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null);
service.addRelationship(alice.getId(), dto);
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
@@ -249,131 +216,6 @@ class RelationshipServiceTest {
.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
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).
@@ -418,15 +260,11 @@ class RelationshipServiceTest {
}
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()
.id(id)
.person(subject)
.relatedPerson(object)
.relationType(type)
.person(parent)
.relatedPerson(child)
.relationType(RelationType.PARENT_OF)
.createdAt(Instant.now())
.build();
}

View File

@@ -100,13 +100,6 @@ class ArchitectureTest {
.and().resideInAPackage("..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.
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
// where it can be audited and reasoned about independently.

View File

@@ -1,61 +0,0 @@
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,7 +12,6 @@ import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -451,74 +450,6 @@ class TagServiceTest {
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 ────────────────────────────────────────────────────────────
@Test

View File

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

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

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

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

@@ -1,552 +0,0 @@
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,29 +538,6 @@ 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.)
### 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
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,18 +168,7 @@ _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).
**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.
**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.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.

View File

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

View File

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

@@ -1,91 +0,0 @@
# 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,28 +6,19 @@ title Component Diagram: API Backend — Timeline (Zeitstrahl)
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
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).")
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(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", "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.")
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(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).")
}
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 (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering")
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves")
Rel(timelineRepo, db, "SQL queries", "JDBC")
Rel(timelineSvc, timelineRepo, "Reads / writes events")
Rel(timelineCtrl, timelineSvc, "Delegates to")
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)")
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)")
Rel(timelineRepo, personDomain, "References persons 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

View File

@@ -14,8 +14,6 @@ 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(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(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(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}.")
@@ -29,9 +27,6 @@ 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(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(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(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")

View File

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

View File

@@ -7,8 +7,6 @@
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
' precision columns; columns only, no new FK relationships, diagram unchanged.
' 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
skinparam linetype ortho

View File

@@ -34,7 +34,6 @@ src/
│ ├── api/ # Internal API proxies (server-side only)
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
│ ├── 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)
│ ├── hilfe/transkription/ # Transcription help page
│ ├── profile/ # User profile settings
@@ -50,7 +49,6 @@ src/
│ │ ├── relationship/ # Relationship form + chip components
│ │ └── genealogy/ # Stammbaum (family tree) components
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
│ ├── geschichte/ # Geschichte (story) domain: editor + card
│ ├── notification/ # Notification bell + dropdown + store
│ ├── activity/ # Activity feed (Chronik) components
@@ -61,8 +59,8 @@ src/
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
│ │ ├── server/ # Server-only utilities (locale, session)
│ │ ├── services/ # Client-side service helpers
│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip)
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.)
│ │ ├── utils/ # Pure utility functions (date, search, etc.)
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.)
│ │ ├── dashboard/ # Dashboard stat components
│ │ ├── discussion/ # CommentThread + shared discussion UI
│ │ ├── 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.
await page.locator('#documentDate').fill('15.01.1928');
await page.locator('#documentDatePrecision').selectOption('YEAR');
await page.locator('#metaDatePrecision').selectOption('YEAR');
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// 4. The detail page shows the regenerated title carrying the new year.

View File

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

@@ -1,93 +0,0 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect, type APIRequestContext } from '@playwright/test';
/**
* Global /zeitstrahl layer filter (#780). Runs against the real stack with the
* seeded admin session (auth.setup). Covers the primary journey (hide the
* Letters layer → letter cards vanish + the trigger reports one active filter →
* reset restores everything) and a 375px axe pass with the collapsible open in
* both light and dark mode.
*
* #779 (the /zeitstrahl route) is merged, so this spec is NOT skipped. Per
* e2e/CLAUDE.md, E2E is not yet wired into CI — this axe gate runs locally only
* for now.
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
const res = await request.post('/api/persons', {
data: { personType: 'PERSON', firstName, lastName }
});
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
return (await res.json()).id as string;
}
/** Seeds one dated letter so the timeline has content (and a LetterCard to hide). */
async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) {
const senderId = await createPerson(request, 'Filter-Test', `Absender ${stamp()}`);
const receiverId = await createPerson(request, 'Filter-Test', `Empfaenger ${stamp()}`);
const createRes = await request.post('/api/documents', { multipart: { title } });
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
const docId = (await createRes.json()).id as string;
const put = await request.put(`/api/documents/${docId}`, {
multipart: {
title,
documentDate: isoDate,
metaDatePrecision: 'DAY',
senderId,
receiverIds: receiverId
}
});
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
}
test.describe('Zeitstrahl — layer filter (#780)', () => {
test('hiding the Letters layer removes letter cards and reports the active count; reset restores', async ({
page,
request
}) => {
// A sparse year keeps the seeded letter an individual card (not a dense strip).
const title = `E2E Filter Brief ${stamp()}`;
await seedDatedLetter(request, '1903-03-03', title);
await page.goto('/zeitstrahl');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByText(title)).toBeVisible();
await page.getByTestId('timeline-filter-trigger').click();
await page.getByTestId('timeline-filter-letters').click();
await expect(page.getByText(title)).toHaveCount(0);
await expect(page.getByTestId('timeline-filter-trigger')).toContainText('1 aktiv');
await page.getByTestId('timeline-filter-reset').click();
await expect(page.getByText(title)).toBeVisible();
});
test('no wcag2a/wcag2aa violations at 375px with the filter bar open (light + dark)', async ({
page,
request
}) => {
await seedDatedLetter(request, '1915-06-15', `E2E Filter A11y ${stamp()}`);
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/zeitstrahl');
await page.waitForSelector('[data-hydrated]');
// Open the collapsible so axe scans the toggles, not just the trigger.
await page.getByTestId('timeline-filter-trigger').click();
await expect(page.getByTestId('timeline-filter-personal')).toBeVisible();
const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
const light = await scan();
expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]);
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const dark = await scan();
expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]);
});
});

View File

@@ -1,84 +0,0 @@
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,12 +199,7 @@ export default defineConfig(
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'conversation' }, 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: 'timeline' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
{
from: { type: 'routes' },
@@ -220,7 +215,6 @@ export default defineConfig(
'ocr',
'activity',
'conversation',
'timeline',
'shared'
]
}

View File

@@ -651,7 +651,6 @@
"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_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_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
@@ -1033,64 +1032,6 @@
"bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum",
"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",
"timeline_filter_label_layers": "Ebenen anzeigen",
"timeline_filter_layer_personal": "Persönliche Ereignisse",
"timeline_filter_layer_historical": "Historische Ereignisse",
"timeline_filter_layer_letters": "Briefe",
"timeline_filter_trigger": "Filter",
"timeline_filter_trigger_active": "Filter ({count} aktiv)",
"timeline_filter_reset": "Filter zurücksetzen",
"timeline_filter_empty_state": "Keine Einträge entsprechen diesen Filtern.",
"event_editor_new_title": "Neues Ereignis",
"event_editor_edit_title": "Ereignis bearbeiten",
"event_editor_section_when": "Wann",
"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_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.",
@@ -1230,16 +1171,6 @@
"relation_form_field_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr",
"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_empty": "Noch keine Beziehungen bekannt.",
"timeline_aria_label": "Zeitachse Dokumentdichte",

View File

@@ -651,7 +651,6 @@
"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_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_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.",
@@ -1033,64 +1032,6 @@
"bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree",
"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",
"timeline_filter_label_layers": "Show layers",
"timeline_filter_layer_personal": "Personal events",
"timeline_filter_layer_historical": "Historical events",
"timeline_filter_layer_letters": "Letters",
"timeline_filter_trigger": "Filter",
"timeline_filter_trigger_active": "Filter ({count} active)",
"timeline_filter_reset": "Reset filters",
"timeline_filter_empty_state": "No entries match these filters.",
"event_editor_new_title": "New event",
"event_editor_edit_title": "Edit event",
"event_editor_section_when": "When",
"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_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.",
@@ -1230,16 +1171,6 @@
"relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year",
"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_empty": "No relationships known yet.",
"timeline_aria_label": "Document density timeline",

View File

@@ -651,7 +651,6 @@
"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_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_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
@@ -1033,64 +1032,6 @@
"bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico",
"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",
"timeline_filter_label_layers": "Mostrar capas",
"timeline_filter_layer_personal": "Eventos personales",
"timeline_filter_layer_historical": "Eventos históricos",
"timeline_filter_layer_letters": "Cartas",
"timeline_filter_trigger": "Filtro",
"timeline_filter_trigger_active": "Filtro ({count} activos)",
"timeline_filter_reset": "Restablecer filtros",
"timeline_filter_empty_state": "Ninguna entrada coincide con estos filtros.",
"event_editor_new_title": "Nuevo evento",
"event_editor_edit_title": "Editar evento",
"event_editor_section_when": "Cuándo",
"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_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.",
@@ -1230,16 +1171,6 @@
"relation_form_field_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año",
"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_empty": "Aún no se conocen relaciones.",
"timeline_aria_label": "Cronología de densidad de documentos",

View File

@@ -11,21 +11,12 @@ interface Props {
selectedDocuments?: DocumentOption[];
placeholder?: 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 {
selectedDocuments = $bindable([]),
placeholder = m.geschichte_editor_search_document(),
hiddenInputName = 'documentIds',
emptyLabel = undefined,
inputId = undefined,
onchange = undefined
hiddenInputName = 'documentIds'
}: Props = $props();
let searchTerm = $state('');
@@ -57,12 +48,10 @@ function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
picker.close();
onchange?.();
}
function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
onchange?.();
}
</script>
@@ -84,7 +73,7 @@ function removeDocument(id: string | undefined) {
<button
type="button"
onclick={() => removeDocument(doc.id)}
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"
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -99,13 +88,8 @@ function removeDocument(id: string | undefined) {
</span>
{/each}
{#if emptyLabel && selectedDocuments.length === 0}
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
{/if}
<input
bind:this={inputEl}
id={inputId}
type="text"
autocomplete="off"
bind:value={searchTerm}

View File

@@ -157,14 +157,4 @@ describe('DocumentMultiSelect — remove', () => {
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
).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,7 +30,6 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`.
- `tag/TagInput.svelte` — tag chip input
- `ocr/OcrProgress.svelte` — job status indicator in the document header
- `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

View File

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

View File

@@ -7,7 +7,7 @@ import {
selectionBoundaryFrom,
selectionBoundaryTo,
formatTickLabel
} from '$lib/shared/utils/monthBuckets';
} from '$lib/document/timeline';
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
import { getLocale } from '$lib/paraglide/runtime';
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 { tick } from 'svelte';
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import { formatTickLabel } from './timeline';
import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api';

View File

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

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte';
import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
@@ -36,6 +37,64 @@ let {
hideDate?: boolean;
editMode?: boolean;
} = $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>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -45,22 +104,79 @@ let {
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
{#if !hideDate}
<!-- Datum + Präzision + Enddatum (shared primitive, #781). The three grid
cells slot directly into this grid; testids are forwarded so the
existing WhoWhenSection selectors survive the extraction. -->
<DatePrecisionField
bind:dateIso={dateIso}
bind:precision={precision}
bind:endDateIso={endDateIso}
initialDateIso={initialDateIso}
suggestedDateIso={suggestedDateIso}
dateInputName="documentDate"
endDateInputName="metaDateEnd"
dateLabel={m.form_label_date()}
dateTestId="who-when-date"
precisionTestId="who-when-precision"
endDateInnerTestId="who-when-end-date"
<!-- Datum (required — row 1, col 1) -->
<div data-testid="who-when-date">
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_date()}*</label
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateInvalid
? '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}
<!-- Absender (required in upload mode — row 1, col 2) -->

View File

@@ -39,17 +39,4 @@ describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', (
const locationInput = document.querySelector('input#location') as HTMLInputElement;
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(() => {
// Invalid → border-red-400 class
expect(dateInput.className).toContain('border-red-400');
expect(document.querySelector('#documentDate-error')).not.toBeNull();
expect(document.querySelector('#date-error')).not.toBeNull();
});
});
it('does not show the error before the user has typed', async () => {
render(WhoWhenSection, {});
const error = document.querySelector('#documentDate-error');
const error = document.querySelector('#date-error');
expect(error).toBeNull();
});
@@ -77,20 +77,20 @@ describe('WhoWhenSection — precision controls', () => {
it('renders a labelled precision select', async () => {
render(WhoWhenSection, {});
const label = document.querySelector('label[for="documentDatePrecision"]');
const select = document.querySelector('select#documentDatePrecision[name="metaDatePrecision"]');
const label = document.querySelector('label[for="metaDatePrecision"]');
const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]');
expect(label).not.toBeNull();
expect(select).not.toBeNull();
});
it('hides the end-date field unless precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'DAY' });
expect(document.querySelector('input#documentDateEnd')).toBeNull();
expect(document.querySelector('input#metaDateEnd')).toBeNull();
});
it('reveals the end-date field when precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'RANGE' });
expect(document.querySelector('input#documentDateEnd')).not.toBeNull();
expect(document.querySelector('input#metaDateEnd')).not.toBeNull();
});
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'
});
const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
await vi.waitFor(() => {
expect(document.querySelector('#documentDate-end-error')).not.toBeNull();
expect(document.querySelector('#end-date-error')).not.toBeNull();
expect(end.getAttribute('aria-invalid')).toBe('true');
expect(end.className).toContain('border-red-400');
});
@@ -125,16 +125,14 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10'
});
await vi.waitFor(() =>
expect(document.querySelector('#documentDate-end-error')).not.toBeNull()
);
await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull());
const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
end.value = '12.01.1917'; // now after the start
end.dispatchEvent(new Event('input', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('#documentDate-end-error')).toBeNull();
expect(document.querySelector('#end-date-error')).toBeNull();
expect(end.getAttribute('aria-invalid')).not.toBe('true');
});
});
@@ -146,6 +144,6 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
endDateIso: '1917-01-10'
});
expect(document.querySelector('#documentDate-end-error')).toBeNull();
expect(document.querySelector('#end-date-error')).toBeNull();
});
});

View File

@@ -1,20 +0,0 @@
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,21 +5,13 @@ import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem'];
/**
* Chip/dedup contract for document pickers. `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 type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
export function createDocumentTypeahead() {
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) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
.then((r) => {
@@ -42,12 +34,9 @@ export function createDocumentTypeahead() {
export function formatDocumentOption(doc: DocumentOption): string {
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(
doc.documentDate,
(doc.metaDatePrecision as DatePrecision) ?? 'DAY',
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()

View File

@@ -1,5 +1,191 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchDensity, buildDensityUrl } from './timeline';
import {
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', () => {
it('returns the bare endpoint when no filters provided', () => {
@@ -123,3 +309,84 @@ describe('fetchDensity', () => {
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,6 +12,160 @@ export type DensityState = {
const SKIP: DensityState = { density: null, 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.
* Date bounds (`from`/`to`) are intentionally excluded — see

View File

@@ -100,22 +100,6 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1048,22 +1032,6 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1656,6 +1624,22 @@ export interface paths {
patch?: 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}": {
parameters: {
query?: never;
@@ -1853,50 +1837,6 @@ export interface components {
provisional: boolean;
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: {
itemIds?: string[];
};
@@ -2052,6 +1992,42 @@ export interface components {
/** Format: uuid */
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: {
lastName: string;
firstName?: string;
@@ -2437,42 +2413,6 @@ export interface components {
contributors: components["schemas"]["ActivityActorDTO"][];
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: {
/** Format: uuid */
id: string;
@@ -2528,10 +2468,10 @@ export interface components {
birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
deathDate?: string;
personType?: string;
familyMember?: boolean;
/** @enum {string} */
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
personType?: string;
familyMember?: boolean;
provisional?: boolean;
/** Format: int32 */
birthYear?: number;
@@ -3208,54 +3148,6 @@ 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: {
parameters: {
query?: never;
@@ -3719,7 +3611,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["RelationshipUpsertRequest"];
"application/json": components["schemas"]["CreateRelationshipRequest"];
};
};
responses: {
@@ -5101,32 +4993,6 @@ 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: {
parameters: {
query?: {
@@ -5965,6 +5831,27 @@ 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: {
parameters: {
query?: never;

View File

@@ -40,87 +40,4 @@ describe('message key parity', () => {
expect(es).toHaveProperty('layout_menu_open');
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' });
});
// #780 REQ-010: the layer-filter strings are Paraglide keys in every locale.
// timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count},
// ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)".
it('zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)', () => {
const requiredKeys = [
'timeline_filter_label_layers',
'timeline_filter_layer_personal',
'timeline_filter_layer_historical',
'timeline_filter_layer_letters',
'timeline_filter_trigger',
'timeline_filter_trigger_active',
'timeline_filter_reset',
'timeline_filter_empty_state'
];
for (const key of requiredKeys) {
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
// the active-count key carries the established {count} placeholder
expect(de.timeline_filter_trigger_active).toContain('{count}');
expect(en.timeline_filter_trigger_active).toContain('{count}');
expect(es.timeline_filter_trigger_active).toContain('{count}');
});
});

View File

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

View File

@@ -1,10 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// 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).
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 {
name,
legend,
@@ -19,21 +26,73 @@ let {
initialPrecision?: string | null;
} = $props();
const precisions: { value: DatePrecision; label: string }[] = $derived([
{ value: 'DAY', label: m.person_precision_day() },
{ value: 'MONTH', label: m.person_precision_month() },
{ value: 'YEAR', label: m.person_precision_year() }
]);
const hint = $derived(`${m.person_precision_hint()} · ${m.person_date_placeholder_hint()}`);
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
// 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>
<DateInputWithPrecision
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
legend={legend}
precisionLabel={precisionLabel}
precisions={precisions}
hint={hint}
initialIso={initialIso}
initialPrecision={initialPrecision}
selectClass="bg-surface"
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,23 +7,9 @@ type Person = components['schemas']['Person'];
interface Props {
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([]),
hiddenInputName = 'receiverIds',
emptyLabel = undefined,
inputId = undefined,
onchange = undefined
}: Props = $props();
let { selectedPersons = $bindable([]) }: Props = $props();
let searchTerm = $state('');
let results: Person[] = $state([]);
@@ -68,19 +54,17 @@ function selectPerson(person: Person) {
searchTerm = '';
showDropdown = false;
results = [];
onchange?.();
}
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter((p) => p.id !== id);
onchange?.();
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person (person.id)}
<input type="hidden" name={hiddenInputName} value={person.id} />
<input type="hidden" name="receiverIds" value={person.id} />
{/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
@@ -95,7 +79,7 @@ function removePerson(id: string | undefined) {
<button
type="button"
onclick={() => removePerson(person.id)}
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"
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -110,13 +94,8 @@ function removePerson(id: string | undefined) {
</span>
{/each}
{#if emptyLabel && selectedPersons.length === 0}
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
{/if}
<input
bind:this={inputEl}
id={inputId}
type="text"
autocomplete="off"
bind:value={searchTerm}

View File

@@ -258,19 +258,6 @@ describe('PersonMultiSelect removing persons', () => {
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 () => {
render(PersonMultiSelect, {
selectedPersons: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,23 @@
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Formats one life date (birth or death) at the precision the data claims.
* Thin domain alias over the shared {@link formatDatePart}: carries no * / †
* glyph — components that need the glyphs wrap them in their own `aria-hidden`
* markup so screen readers only hear the date.
* Formats one life date (birth or death) at the precision the data claims,
* delegating all rendering to {@link formatDocumentDate}. Returns '' for a
* missing date. Carries no * / † glyph — components that need the glyphs wrap
* them in their own `aria-hidden` 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(
date: string | null | undefined,
precision: DatePrecision | null | undefined,
locale?: string
): string {
return formatDatePart(date, precision, locale);
if (!date) {
return '';
}
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
}
/**

View File

@@ -1,11 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>;
@@ -13,96 +10,71 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = {
relatedPersonId: string;
relationType: RelationType;
fromDate?: string;
fromDatePrecision?: DatePrecision;
toDate?: string;
toDatePrecision?: DatePrecision;
notes?: string;
fromYear?: number;
toYear?: number;
};
interface Props {
personId: string;
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
relationship?: RelationshipDTO;
onSubmit?: (data: RelFormData) => Promise<void>;
onClose?: () => void;
}
let { personId, relationship, onSubmit, onClose }: Props = $props();
const isEdit = $derived(relationship != null);
let { personId, onSubmit }: Props = $props();
let open = $state(false);
let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state('');
let addRelatedPersonName = $state('');
let notes = $state('');
let addFromYear = $state('');
let addToYear = $state('');
let callbackError = $state<string | null>(null);
let submitting = $state(false);
// Seed once at mount (reading props in a closure avoids state_referenced_locally).
// The parent re-creates this form per edited row, so the relationship never
// changes under a live instance.
onMount(() => {
if (!relationship) return;
open = true;
addType = relationship.relationType ?? 'PARENT_OF';
const viewpointIsSubject = relationship.personId === personId;
addRelatedPersonId =
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
addRelatedPersonName =
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
'';
notes = relationship.notes ?? '';
const yearError = $derived.by(() => {
const from = addFromYear.trim();
const to = addToYear.trim();
if (!from || !to) return null;
const fromInt = parseInt(from, 10);
const toInt = parseInt(to, 10);
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
});
const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
);
const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
const submitDisabled = $derived(
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
function reset() {
addType = 'PARENT_OF';
addRelatedPersonId = '';
addRelatedPersonName = '';
notes = '';
addFromYear = '';
addToYear = '';
callbackError = null;
}
function cancel() {
if (isEdit) {
onClose?.();
return;
}
open = false;
reset();
}
async function handleCallbackSubmit(event: SubmitEvent) {
async function handleCallbackSubmit(event: Event) {
event.preventDefault();
if (submitDisabled || !onSubmit) return;
const fd = new FormData(event.currentTarget as HTMLFormElement);
const fromDate = (fd.get('fromDate') as string) || undefined;
const toDate = (fd.get('toDate') as string) || undefined;
const data: RelFormData = {
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;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
const from = parseInt(addFromYear.trim(), 10);
if (!Number.isNaN(from)) data.fromYear = from;
const to = parseInt(addToYear.trim(), 10);
if (!Number.isNaN(to)) data.toYear = to;
try {
await onSubmit(data);
open = false;
reset();
} catch {
callbackError = m.error_internal_error();
} finally {
submitting = false;
}
}
</script>
@@ -141,32 +113,39 @@ async function handleCallbackSubmit(event: SubmitEvent) {
compact
/>
</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}
<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"
/>
<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>
<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>
{#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if}
@@ -183,18 +162,10 @@ async function handleCallbackSubmit(event: SubmitEvent) {
</button>
<button
type="submit"
disabled={submitDisabled || submitting}
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"
disabled={submitDisabled}
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"
>
{#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()}
{m.relation_btn_add()}
</button>
</div>
{/snippet}
@@ -214,27 +185,18 @@ async function handleCallbackSubmit(event: SubmitEvent) {
{:else}
<form
method="POST"
action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
action="?/addRelationship"
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
await update();
submitting = false;
if (result.type === 'success') {
if (isEdit) {
onClose?.();
} else {
open = false;
reset();
}
}
};
}}
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()}
</form>
{/if}

View File

@@ -8,116 +8,58 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup);
const PID = 'person-1';
const OTHER = 'person-2';
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 });
describe('AddRelationshipForm', () => {
it('shows add-relationship button initially and no form', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
await expect.element(page.getByRole('button')).toBeInTheDocument();
expect(document.querySelector('select[name="relationType"]')).toBeNull();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('shows the relationType select when the add toggle is clicked', async () => {
render(AddRelationshipForm, { personId: PID });
it('shows relationType select when add button is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
it('hides the form and shows the toggle again on cancel', async () => {
render(AddRelationshipForm, { personId: PID });
it('hides form and shows button when cancel is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
);
cancelBtn!.click();
await vi.waitFor(() =>
expect(document.querySelector('select[name="relationType"]')).toBeNull()
);
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('disables submit when no person is selected', async () => {
render(AddRelationshipForm, { personId: PID });
it('submit is disabled when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
});
it('has no server action when an onSubmit prop is provided', async () => {
it('form has no server action when onSubmit prop is provided', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: PID, onSubmit });
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
expect(document.querySelector('form')?.hasAttribute('action')).toBe(false);
});
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const form = document.querySelector('form');
expect(form?.hasAttribute('action')).toBe(false);
});
describe('AddRelationshipForm — edit mode', () => {
it('opens pre-filled and labels the submit "Speichern"', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument();
});
it('shows year-range error when toYear is before fromYear', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
it('pre-fills the from-date as dd.mm.yyyy', async () => {
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const fromInput = document.querySelector<HTMLInputElement>('#fromDate')!;
await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923'));
});
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935';
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
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'));
});
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
toInput.value = '1920';
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
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();
await expect.element(page.getByRole('alert')).toBeVisible();
});
});

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