Compare commits
43 Commits
feat/issue
...
093c942f67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
093c942f67 | ||
|
|
bd78f34f09 | ||
|
|
9ad18f92d9 | ||
|
|
b08f86f76d | ||
|
|
c1dc58c24f | ||
|
|
4162cfa916 | ||
|
|
74bf1d864c | ||
|
|
ce6afd3bd0 | ||
|
|
ea1034f9ce | ||
|
|
23534fb077 | ||
|
|
ca06293dc5 | ||
|
|
dd97418e24 | ||
|
|
b54a35322b | ||
|
|
9551bbd1ca | ||
|
|
38250606d9 | ||
|
|
6c85f47794 | ||
|
|
5936f3a9ae | ||
|
|
8be4b40e54 | ||
|
|
bc22b2d4c9 | ||
|
|
f3c2465465 | ||
|
|
fd67a21610 | ||
|
|
0ae4e9a311 | ||
|
|
99528e6bea | ||
|
|
4b11d66ca5 | ||
|
|
0726226c95 | ||
|
|
e613a93213 | ||
|
|
f57e59b53c | ||
|
|
07771a7b34 | ||
|
|
4d4266ba99 | ||
|
|
446611e3cc | ||
|
|
9118a10e4b | ||
|
|
11bcaf7cdb | ||
|
|
cd238285ae | ||
|
|
ec0e4dfa45 | ||
|
|
d134990343 | ||
|
|
21b1b3b835 | ||
|
|
33aff36867 | ||
|
|
e18282318a | ||
|
|
c6fe61f06b | ||
|
|
182d014971 | ||
|
|
dc9d1d52b3 | ||
| 8558567688 | |||
|
|
6dae4fe428 |
@@ -192,17 +192,52 @@ jobs:
|
|||||||
REPO="${{ github.repository }}"
|
REPO="${{ github.repository }}"
|
||||||
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
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) ---
|
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
||||||
# Tests the exact jq test() call used in the dedupe step, before any
|
# Runs before any real API call so broken logic fails loudly early:
|
||||||
# API call, so a broken matcher fails loudly early rather than silently
|
# (a) the jq title matcher used by the dedupe step — proves the regex
|
||||||
# opening duplicate issues. Proves the regex only — create-vs-update
|
# only; the create-vs-update decision is exercised by the
|
||||||
# decision is exercised by the workflow_dispatch AC.
|
# workflow_dispatch AC;
|
||||||
|
# (b) the api helper's HTTP-status handling, driven by a mocked curl so
|
||||||
|
# it needs no network — proves a 2xx returns the body and a >=400
|
||||||
|
# fails with an ::error:: instead of an opaque exit 22.
|
||||||
echo "{\"title\": \"${MARKER}\"}" \
|
echo "{\"title\": \"${MARKER}\"}" \
|
||||||
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
||||||
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
||||||
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
||||||
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
||||||
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
|| { 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."
|
echo "Self-test passed."
|
||||||
|
|
||||||
# --- Run audit ---
|
# --- Run audit ---
|
||||||
@@ -237,8 +272,7 @@ jobs:
|
|||||||
# Renovate vuln PRs also carry the "security" label, so >1 open
|
# Renovate vuln PRs also carry the "security" label, so >1 open
|
||||||
# "security" issue WILL occur. Title-match (not just label) ensures
|
# "security" issue WILL occur. Title-match (not just label) ensures
|
||||||
# we deduplicate only our own tracking issue.
|
# we deduplicate only our own tracking issue.
|
||||||
OPEN_ISSUES=$(curl -sf \
|
OPEN_ISSUES=$(api GET \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
||||||
|
|
||||||
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
||||||
@@ -255,11 +289,10 @@ jobs:
|
|||||||
--arg run_url "$RUN_URL" \
|
--arg run_url "$RUN_URL" \
|
||||||
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
||||||
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
||||||
curl -sf -X PATCH \
|
api PATCH \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD" > /dev/null
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
|
|
||||||
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
||||||
else
|
else
|
||||||
# Closed prior issue that recurs → new issue (not reopened).
|
# Closed prior issue that recurs → new issue (not reopened).
|
||||||
@@ -268,24 +301,21 @@ jobs:
|
|||||||
--arg title "$MARKER" \
|
--arg title "$MARKER" \
|
||||||
--arg body "$ISSUE_BODY" \
|
--arg body "$ISSUE_BODY" \
|
||||||
'{"title": $title, "body": $body}')
|
'{"title": $title, "body": $body}')
|
||||||
CREATED=$(curl -sf -X POST \
|
CREATED=$(api POST \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD")
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
|
|
||||||
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
||||||
echo "Opened new tracking issue #${NEW_NUMBER}"
|
echo "Opened new tracking issue #${NEW_NUMBER}"
|
||||||
|
|
||||||
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
||||||
LABEL_IDS=$(curl -sf \
|
LABEL_IDS=$(api GET \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
||||||
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
||||||
curl -sf -X POST \
|
api POST \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"labels\": $LABEL_IDS}" \
|
-d "{\"labels\": $LABEL_IDS}" > /dev/null
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit "$AUDIT_EXIT"
|
exit "$AUDIT_EXIT"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
|
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
|
||||||
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
|
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
|
||||||
> matrix is the part of the spec that *is* committed: it links each requirement to its issue,
|
> matrix is the part of the spec that _is_ committed: it links each requirement to its issue,
|
||||||
> the code that implements it, and the test(s) that prove it — so any requirement traces end
|
> the code that implements it, and the test(s) that prove it — so any requirement traces end
|
||||||
> to end, and any orphan (a requirement with no test) is visible on `main`.
|
> to end, and any orphan (a requirement with no test) is visible on `main`.
|
||||||
|
|
||||||
@@ -25,16 +25,16 @@
|
|||||||
## Matrix
|
## Matrix
|
||||||
|
|
||||||
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|
||||||
|---|---|---|---|---|---|---|
|
| ------- | ---------------------------------------------------------------------- | -------- | ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
|
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
|
||||||
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
|
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (\_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
|
||||||
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
|
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
|
||||||
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
|
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (\_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
|
||||||
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
|
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
|
||||||
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
|
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (\_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
|
||||||
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
|
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (\_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
|
||||||
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
|
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (\_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
|
||||||
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
|
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (\_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
|
||||||
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
|
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
|
||||||
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
|
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
|
||||||
| REQ-003 | `UNKNOWN` → `null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
|
| REQ-003 | `UNKNOWN` → `null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
|
||||||
@@ -43,11 +43,12 @@
|
|||||||
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
|
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
|
||||||
|
|
||||||
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
|
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
|
||||||
|
|
||||||
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
|
| REQ-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-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-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-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
|
||||||
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
|
| REQ-005 | SPOUSE*OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
|
||||||
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
|
| REQ-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-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-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 |
|
||||||
@@ -139,6 +140,25 @@
|
|||||||
| 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-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-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-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-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-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-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 |
|
||||||
@@ -154,3 +174,45 @@
|
|||||||
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
|
| REQ-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-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-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
|
||||||
|
| REQ-001 | TimelineFilters is presentation-only (3 $bindable layer booleans + onChange); no goto/url.searchParams/api.GET/fetch | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#renders the three layer toggles with accessible names`, `#reflects a layer as pressed and flips it, firing onChange`; `timelineFilterBoundary.spec.ts` | Done |
|
||||||
|
| REQ-002 | route derives a client-side $derived filtered view, passes it to TimelineView; no goto/fetch on toggle | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#hides letter cards when the Letters layer is off ... with no fetch`; `timelineFilterBoundary.spec.ts` | Done |
|
||||||
|
| REQ-003 | Personal off → personal events (curated + derived life-events) hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides personal events — curated and derived`, `page.svelte.spec.ts#hides personal event cards` | Done |
|
||||||
|
| REQ-004 | Historical off → historical event entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides HISTORICAL events`, `page.svelte.spec.ts#hides historical event cards` | Done |
|
||||||
|
| REQ-005 | Letters off → letter entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides LETTER entries`, `page.svelte.spec.ts#hides letter cards` | Done |
|
||||||
|
| REQ-006 | zero visible → filter empty-state + one-click reset below the open bar (never blank, never generic empty) | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#shows the filtered-empty message + reset below the open bar`, `timelineFilter.spec.ts#drops year bands that become empty` | Done |
|
||||||
|
| REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done |
|
||||||
|
| REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done |
|
||||||
|
| REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done |
|
||||||
|
| REQ-010 | 8 timeline_filter*_ keys in de/en/es; trigger vs trigger*active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
|
||||||
|
| REQ-001 | WRITE_ALL viewer → "Ereignis hinzufügen" link to /zeitstrahl/events/new in the wrapping Zeitstrahl header | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/messages/{de,en,es}.json` | `zeitstrahl/page.svelte.spec.ts#renders the add-event CTA in a wrapping header when the viewer can write` | Done |
|
||||||
|
| REQ-002 | viewer without WRITE_ALL → no add-event affordance on /zeitstrahl | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#renders no add-event CTA when the viewer cannot write` | Done |
|
||||||
|
| REQ-003 | WRITE_ALL viewer → person-page "Ereignis für diese Person" link to /zeitstrahl/events/new?personId={id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/messages/{de,en,es}.json` | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` | Done |
|
||||||
|
| REQ-004 | viewer without WRITE_ALL → no add-event affordance on /persons/{id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte` | `PersonCard.svelte.spec.ts#renders no add-event link to a reader` | Done |
|
||||||
|
| REQ-005 | WRITE_ALL → EventPill edit link /zeitstrahl/events/{eventId}/edit for a curated PERSONAL event | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#shows an edit affordance for a curated PERSONAL event when canWrite is true` | Done |
|
||||||
|
| REQ-006 | WRITE_ALL → WorldBand edit link /zeitstrahl/events/{eventId}/edit for a curated HISTORICAL event (new inline ✎) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#shows an edit affordance for a curated HISTORICAL event when canWrite is true`, `#mirrors the EventPill pencil` | Done |
|
||||||
|
| REQ-007 | viewer without WRITE_ALL → neither EventPill nor WorldBand renders an edit link | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#renders no edit affordance for a curated PERSONAL event when canWrite is false`, `WorldBand.svelte.spec.ts#renders no edit affordance for a curated HISTORICAL event when canWrite is false`, `TimelineView.svelte.spec.ts#renders no edit links in either path when canWrite is false` | Done |
|
||||||
|
| REQ-008 | derived OR null eventId → no edit link regardless of permission (contract preserved) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite`, `#shows no edit affordance for a derived event even with canWrite`, `WorldBand.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite` | Done |
|
||||||
|
| REQ-009 | TimelineView threads canWrite through the year-band path and the undated bucket (identical gate) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `TimelineView.svelte.spec.ts#threads canWrite to a curated event in both a year band and the undated bucket`, `#threads canWrite to a curated HISTORICAL world band in both paths` | Done |
|
||||||
|
| REQ-010 | person add-event opens #781 create form prefilled with the person, returns to /persons/{id} on save | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (#781) | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` (URL), `zeitstrahl/events/new/page.server.spec.ts#redirects to /persons/{id} when originPersonId is a valid UUID` (#781) | Done |
|
||||||
|
| REQ-011 | non-curator direct nav to /zeitstrahl/events/new or /{id}/edit → 403 (existing #781 route guard, regression) | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (#781, unchanged — both share `requireWriteAll`) | `zeitstrahl/events/new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `zeitstrahl/events/[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
|
||||||
|
| REQ-001 | axis-fixed layers (life-events, pills, world-bands) render identically across all 3 modes; only loose letters re-bundle | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `grouping-event-layer-identity.svelte.spec.ts#renders the event pills and world-bands identically across all three grouping modes`, `YearBand.svelte.spec.ts#still renders the event world-band in Ereignis mode` | Done |
|
||||||
|
| REQ-002 | mode switch re-bundles loose letters over the layer-filtered view, no GET /api/timeline refetch | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/TimelineView.svelte` | `zeitstrahl/page.svelte.spec.ts#regroups loose letters under their event client-side`, `e2e/zeitstrahl-grouping.spec.ts#switching grouping modes issues no extra timeline fetch` | Done |
|
||||||
|
| REQ-003 | Ereignis clusters each loose letter under the curated event whose documents contain it | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#clusters letters under the curated event named by linkedEventId`, `YearBand.svelte.spec.ts#clusters loose letters under their linked event in Ereignis mode` | Done |
|
||||||
|
| REQ-004 | Thema buckets each loose letter per year under its primary root tag (rootTagId) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#bucketLetters`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#buckets letters under their primary root tag with name and colour`, `YearBand.svelte.spec.ts#buckets loose letters under their root tag in Thema mode` | Done |
|
||||||
|
| REQ-005 | TimelineEntryDTO carries nullable linkedEventId, resolved in one batched membership pass | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id` | Done |
|
||||||
|
| REQ-005b | linkedEventId is nullable / not @Schema REQUIRED; null for non-letter entries | #827 | timeline-grouping-modes | `backend/.../timeline/TimelineEntryDTO.java`, `frontend/src/lib/generated/api.ts` (`linkedEventId?`) | `TimelineServiceTest#letter_in_no_curated_event_has_null_linkedEventId` | Done |
|
||||||
|
| REQ-006 | Ereignis: letter with null linkedEventId → per-year "Weitere Briefe" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops a letter with no linkedEventId into the fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Weitere Briefe" label` | Done |
|
||||||
|
| REQ-007 | Thema: untagged letter → per-year "Ohne Thema" bucket | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts`, `frontend/src/lib/timeline/LetterBucket.svelte` | `timelineGrouping.spec.ts#drops an untagged letter into the "Ohne Thema" fallback bucket`, `LetterBucket.svelte.spec.ts#uses the localized "Ohne Thema" label` | Done |
|
||||||
|
| REQ-008 | multi-tagged letter appears under exactly one root tag, never duplicated | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts` | `timelineGrouping.spec.ts#places a letter in exactly one bucket` | Done |
|
||||||
|
| REQ-009 | tag names + hint render via `{...}` escaping; grep gate forbids `{@html}` in lib/timeline | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte`, `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/TagChip.svelte` | `BucketHeaderChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive` | Done |
|
||||||
|
| REQ-010 | grouping control is a keyboard-navigable role=radiogroup, ≥44px text segments, default Datum, dark-mode contrast | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#renders three radios inside a radiogroup`, `#moves the selection forward with the right arrow key`, `#each segment has a tap target of at least 44×44px`, `#defaults to Datum`; `e2e/zeitstrahl-grouping.spec.ts#no wcag2a/wcag2aa violations ... (light + dark)` | Done |
|
||||||
|
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
|
||||||
|
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
|
||||||
|
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
|
||||||
|
| REQ-014 | Ereignis event-clustered letters live inside a **contained card whose header is the same-year curated event** (glyph, title, date, provenance, edit pencil) — the title reads once, no separate floating pill; letters render as the compact `.lcard.ev` variant, first 5 + show-more (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders the curated event as the card header when given an `event` (no separate pill)`, `LetterBucket.svelte.spec.ts#shows no edit affordance in the header when canWrite is false`, `YearBand.svelte.spec.ts#renders a same-year curated event as one card header, with no separate pill and no duplicate title` | Done |
|
||||||
|
| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done |
|
||||||
|
| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done |
|
||||||
|
| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done |
|
||||||
|
| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done |
|
||||||
|
| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done |
|
||||||
|
| REQ-020 | Grouped clusters are **contained colour-railed cards** (bordered, rounded, surface) carrying compact cards; a cluster shows the first `CLUSTER_PREVIEW` (5) letters behind a show-more toggle, and the leftover bin is a **collapsed count-only drawer** revealed on demand — the month-density `YearLetterStrip` is no longer used in grouped mode (still used in Datum dense years) (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#CLUSTER_PREVIEW`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#renders the cluster as a contained card (bordered, rounded, surface)`, `#binds a tag bucket together with a coloured left rail from its token`, `#shows only the first 5 letters with a show-more toggle when the cluster is larger`, `#expands to all letters and collapses back on toggle`, `#renders collapsed — count + reveal, no letter cards — until opened`, `#reveals the first 5 letters when opened`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-field validation and normalization shared by every domain that stores a
|
||||||
|
* {@link LocalDate} + {@link DatePrecision} pair — a person's life dates (ADR-039 / V76)
|
||||||
|
* and a relationship's from/to dates (ADR-044 / V78). Kept out of {@link DatePrecision}
|
||||||
|
* itself because that enum is a frozen contract mirror of the import normalizer (ADR-025)
|
||||||
|
* and must carry no behaviour.
|
||||||
|
*/
|
||||||
|
public final class DatePrecisionValidation {
|
||||||
|
|
||||||
|
private DatePrecisionValidation() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the date ⇔ precision coherence the V76/V78 CHECK constraints also enforce:
|
||||||
|
* a date requires a non-{@code UNKNOWN} precision, and a non-{@code UNKNOWN} precision
|
||||||
|
* requires a date. Validated in-service so the caller gets a structured 400 instead of
|
||||||
|
* the database constraint's raw 500.
|
||||||
|
*
|
||||||
|
* @param side human-readable field label woven into the error message ("birth", "from", …)
|
||||||
|
*/
|
||||||
|
public static void requireCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||||
|
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date is set but its precision is missing or UNKNOWN");
|
||||||
|
}
|
||||||
|
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date precision " + precision + " is set without a date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A null precision means "no precision recorded" → {@link DatePrecision#UNKNOWN}. */
|
||||||
|
public static DatePrecision normalize(DatePrecision precision) {
|
||||||
|
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,8 @@ public enum ErrorCode {
|
|||||||
CIRCULAR_RELATIONSHIP,
|
CIRCULAR_RELATIONSHIP,
|
||||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
||||||
DUPLICATE_RELATIONSHIP,
|
DUPLICATE_RELATIONSHIP,
|
||||||
|
/** A relationship's toDate is before its fromDate. 400 */
|
||||||
|
INVALID_RELATIONSHIP_DATES,
|
||||||
|
|
||||||
// --- Geschichten (Stories) ---
|
// --- Geschichten (Stories) ---
|
||||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
|
|||||||
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
||||||
try {
|
try {
|
||||||
relationshipService.addRelationship(person,
|
relationshipService.addRelationship(person,
|
||||||
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
|
new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null));
|
||||||
return true;
|
return true;
|
||||||
} catch (DomainException e) {
|
} catch (DomainException e) {
|
||||||
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
|||||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -448,41 +449,28 @@ public class PersonService {
|
|||||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||||
.birthDate(dto.getBirthDate())
|
.birthDate(dto.getBirthDate())
|
||||||
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
|
||||||
.deathDate(dto.getDeathDate())
|
.deathDate(dto.getDeathDate())
|
||||||
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
|
||||||
.generation(dto.getGeneration())
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||||
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
// user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence
|
||||||
|
// is shared with the relationship domain (DatePrecisionValidation); only the order check
|
||||||
|
// (and its BIRTH_AFTER_DEATH code) is life-date specific.
|
||||||
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||||
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
|
||||||
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
|
||||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
|
||||||
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
|
||||||
side + " date is set but its precision is missing or UNKNOWN");
|
|
||||||
}
|
|
||||||
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
|
||||||
side + " date precision " + precision + " is set without a date");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
|
||||||
return precision == null ? DatePrecision.UNKNOWN : precision;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
@@ -499,9 +487,9 @@ public class PersonService {
|
|||||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||||
person.setBirthDate(dto.getBirthDate());
|
person.setBirthDate(dto.getBirthDate());
|
||||||
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
|
||||||
person.setDeathDate(dto.getDeathDate());
|
person.setDeathDate(dto.getDeathDate());
|
||||||
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
|
||||||
// Form path: a human can clear generation back to null. Unlike the importer
|
// Form path: a human can clear generation back to null. Unlike the importer
|
||||||
// which routes through preferHuman, we write the DTO value verbatim.
|
// which routes through preferHuman, we write the DTO value verbatim.
|
||||||
person.setGeneration(dto.getGeneration());
|
person.setGeneration(dto.getGeneration());
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -39,11 +41,25 @@ public class PersonRelationship {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private RelationType relationType;
|
private RelationType relationType;
|
||||||
|
|
||||||
@Column(name = "from_year")
|
// Start/end of the relationship (wedding, employment start, …). The date column
|
||||||
private Integer fromYear;
|
// is nullable, the precision column is NOT NULL with UNKNOWN meaning "no date" —
|
||||||
|
// the V78 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN) and
|
||||||
|
// from_date <= to_date. Mirrors Person.{birth,death}Date (ADR-039 / ADR-044).
|
||||||
|
private LocalDate fromDate;
|
||||||
|
|
||||||
@Column(name = "to_year")
|
@Enumerated(EnumType.STRING)
|
||||||
private Integer toYear;
|
@Column(name = "from_date_precision", nullable = false, length = 16)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
|
private LocalDate toDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "to_date_precision", nullable = false, length = 16)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private DatePrecision toDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
@Column(length = 2000)
|
@Column(length = 2000)
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -63,11 +63,20 @@ public class RelationshipController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<RelationshipDTO> addRelationship(
|
public ResponseEntity<RelationshipDTO> addRelationship(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@Valid @RequestBody CreateRelationshipRequest dto) {
|
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
||||||
return ResponseEntity.status(HttpStatus.CREATED)
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
.body(relationshipService.addRelationship(id, dto));
|
.body(relationshipService.addRelationship(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/persons/{id}/relationships/{relId}")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public RelationshipDTO updateRelationship(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID relId,
|
||||||
|
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
||||||
|
return relationshipService.updateRelationship(id, relId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
@@ -96,65 +98,129 @@ public class RelationshipService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
|
||||||
if (personId.equals(dto.relatedPersonId())) {
|
requireNotSelf(personId, dto.relatedPersonId());
|
||||||
throw DomainException.badRequest(
|
|
||||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
|
||||||
}
|
|
||||||
Person person = personService.getById(personId);
|
Person person = personService.getById(personId);
|
||||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||||
|
|
||||||
validateYears(dto.fromYear(), dto.toYear());
|
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
||||||
|
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
|
||||||
if (dto.relationType() == RelationType.PARENT_OF
|
|
||||||
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
|
||||||
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
|
|
||||||
throw DomainException.conflict(
|
|
||||||
ErrorCode.CIRCULAR_RELATIONSHIP,
|
|
||||||
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonRelationship rel = PersonRelationship.builder()
|
PersonRelationship rel = PersonRelationship.builder()
|
||||||
.person(person)
|
.person(person)
|
||||||
.relatedPerson(relatedPerson)
|
.relatedPerson(relatedPerson)
|
||||||
.relationType(dto.relationType())
|
.relationType(dto.relationType())
|
||||||
.fromYear(dto.fromYear())
|
.fromDate(dto.fromDate())
|
||||||
.toYear(dto.toYear())
|
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
|
||||||
|
.toDate(dto.toDate())
|
||||||
|
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
|
||||||
.notes(blankToNull(dto.notes()))
|
.notes(blankToNull(dto.notes()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
PersonRelationship saved;
|
PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
|
||||||
try {
|
flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
|
||||||
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
|
||||||
// caught here, not at commit time outside the @Transactional boundary.
|
|
||||||
saved = relationshipRepository.saveAndFlush(rel);
|
|
||||||
} catch (DataIntegrityViolationException e) {
|
|
||||||
throw DomainException.conflict(
|
|
||||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
|
||||||
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
|
||||||
}
|
|
||||||
// Family-graph edges imply both endpoints are family members. Idempotent: the
|
|
||||||
// setter is a no-op when the person is already flagged, so re-imports stay clean.
|
|
||||||
if (FAMILY_RELATION_TYPES.contains(dto.relationType())) {
|
|
||||||
personService.setFamilyMember(person.getId(), true);
|
|
||||||
personService.setFamilyMember(relatedPerson.getId(), true);
|
|
||||||
}
|
|
||||||
return toDTO(saved);
|
return toDTO(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public RelationshipDTO updateRelationship(UUID personId, UUID relId, RelationshipUpsertRequest dto) {
|
||||||
|
PersonRelationship rel = loadOwnedRelationship(personId, relId);
|
||||||
|
|
||||||
|
// The other party from {personId}'s viewpoint cannot be {personId} itself.
|
||||||
|
requireNotSelf(personId, dto.relatedPersonId());
|
||||||
|
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
||||||
|
|
||||||
|
// Preserve the directed orientation: {personId} keeps whichever role (subject or
|
||||||
|
// object) it already holds on the row, and the edited "related person" takes the
|
||||||
|
// other role. So a PARENT_OF edge stays parent→child whether the curator edits it
|
||||||
|
// from the parent's page or the child's.
|
||||||
|
boolean viewpointIsSubject = personId.equals(rel.getPerson().getId());
|
||||||
|
Person viewpoint = viewpointIsSubject ? rel.getPerson() : rel.getRelatedPerson();
|
||||||
|
Person other = personService.getById(dto.relatedPersonId());
|
||||||
|
Person newSubject = viewpointIsSubject ? viewpoint : other;
|
||||||
|
Person newObject = viewpointIsSubject ? other : viewpoint;
|
||||||
|
|
||||||
|
requireNoReverseParent(newSubject.getId(), newObject.getId(), dto.relationType());
|
||||||
|
|
||||||
|
rel.setPerson(newSubject);
|
||||||
|
rel.setRelatedPerson(newObject);
|
||||||
|
rel.setRelationType(dto.relationType());
|
||||||
|
rel.setFromDate(dto.fromDate());
|
||||||
|
rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
|
||||||
|
rel.setToDate(dto.toDate());
|
||||||
|
rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
|
||||||
|
rel.setNotes(blankToNull(dto.notes()));
|
||||||
|
|
||||||
|
PersonRelationship saved = persistOrConflict(rel, newSubject.getId(), newObject.getId(), dto.relationType());
|
||||||
|
flagFamilyMembership(dto.relationType(), newSubject.getId(), newObject.getId());
|
||||||
|
return toDTO(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared create/update invariants ---------------------------------------------
|
||||||
|
|
||||||
|
// A person cannot be related to themselves, from either viewpoint.
|
||||||
|
private static void requireNotSelf(UUID viewpointId, UUID relatedPersonId) {
|
||||||
|
if (viewpointId.equals(relatedPersonId)) {
|
||||||
|
throw DomainException.badRequest(
|
||||||
|
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PARENT_OF edge must not already have its mirror (child PARENT_OF parent) stored —
|
||||||
|
// that would be a cycle. No-op for every other relation type.
|
||||||
|
private void requireNoReverseParent(UUID subjectId, UUID objectId, RelationType type) {
|
||||||
|
if (type == RelationType.PARENT_OF
|
||||||
|
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
objectId, subjectId, RelationType.PARENT_OF)) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.CIRCULAR_RELATIONSHIP,
|
||||||
|
"Reverse PARENT_OF already exists between " + subjectId + " and " + objectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveAndFlush so the unique_rel constraint violates synchronously and is caught here,
|
||||||
|
// inside the @Transactional boundary, not at commit time as a raw 500.
|
||||||
|
private PersonRelationship persistOrConflict(PersonRelationship rel, UUID subjectId, UUID objectId, RelationType type) {
|
||||||
|
try {
|
||||||
|
return relationshipRepository.saveAndFlush(rel);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||||
|
"Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family-graph edges imply both endpoints are family members. Idempotent (the setter is
|
||||||
|
// a no-op when already flagged, so re-imports stay clean) and additive — an edit never
|
||||||
|
// auto-unflags.
|
||||||
|
private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) {
|
||||||
|
if (FAMILY_RELATION_TYPES.contains(type)) {
|
||||||
|
personService.setFamilyMember(subjectId, true);
|
||||||
|
personService.setFamilyMember(objectId, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteRelationship(UUID personId, UUID relId) {
|
public void deleteRelationship(UUID personId, UUID relId) {
|
||||||
|
PersonRelationship rel = loadOwnedRelationship(personId, relId);
|
||||||
|
relationshipRepository.delete(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the row and verifies {personId} is one of its endpoints. A mismatch is 404
|
||||||
|
// (not 403): an anti-enumeration choice so a curator cannot probe relationship ids
|
||||||
|
// belonging to people they cannot see. Shared by update + delete for consistency.
|
||||||
|
private PersonRelationship loadOwnedRelationship(UUID personId, UUID relId) {
|
||||||
PersonRelationship rel = relationshipRepository.findById(relId)
|
PersonRelationship rel = relationshipRepository.findById(relId)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
||||||
|
|
||||||
UUID storageSubject = rel.getPerson().getId();
|
UUID storageSubject = rel.getPerson().getId();
|
||||||
UUID storageObject = rel.getRelatedPerson().getId();
|
UUID storageObject = rel.getRelatedPerson().getId();
|
||||||
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
||||||
throw DomainException.forbidden(
|
throw DomainException.notFound(
|
||||||
|
ErrorCode.RELATIONSHIP_NOT_FOUND,
|
||||||
"Relationship " + relId + " does not belong to person " + personId);
|
"Relationship " + relId + " does not belong to person " + personId);
|
||||||
}
|
}
|
||||||
relationshipRepository.delete(rel);
|
return rel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -173,10 +239,17 @@ public class RelationshipService {
|
|||||||
return date != null ? date.getYear() : null;
|
return date != null ? date.getYear() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
|
||||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
// user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
|
||||||
throw DomainException.badRequest(
|
// Coherence is shared with the person domain (DatePrecisionValidation); only the order
|
||||||
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
// check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
|
||||||
|
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
|
||||||
|
LocalDate toDate, DatePrecision toPrecision) {
|
||||||
|
DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
|
||||||
|
DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
|
||||||
|
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
|
||||||
|
"toDate " + toDate + " is before fromDate " + fromDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +267,10 @@ public class RelationshipService {
|
|||||||
yearOf(rp.getBirthDate()),
|
yearOf(rp.getBirthDate()),
|
||||||
yearOf(rp.getDeathDate()),
|
yearOf(rp.getDeathDate()),
|
||||||
r.getRelationType(),
|
r.getRelationType(),
|
||||||
r.getFromYear(),
|
r.getFromDate(),
|
||||||
r.getToYear(),
|
r.getFromDatePrecision(),
|
||||||
|
r.getToDate(),
|
||||||
|
r.getToDatePrecision(),
|
||||||
r.getNotes());
|
r.getNotes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record CreateRelationshipRequest(
|
|
||||||
@NotNull UUID relatedPersonId,
|
|
||||||
@NotNull RelationType relationType,
|
|
||||||
Integer fromYear,
|
|
||||||
Integer toYear,
|
|
||||||
@Size(max = 2000) String notes
|
|
||||||
) {}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +28,9 @@ public record RelationshipDTO(
|
|||||||
Integer relatedPersonBirthYear,
|
Integer relatedPersonBirthYear,
|
||||||
Integer relatedPersonDeathYear,
|
Integer relatedPersonDeathYear,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
||||||
Integer fromYear,
|
LocalDate fromDate,
|
||||||
Integer toYear,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision,
|
||||||
|
LocalDate toDate,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision,
|
||||||
String notes
|
String notes
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for both creating and updating a relationship — the fields are
|
||||||
|
* identical, so one record serves {@code POST} and {@code PUT} (DRY). A null
|
||||||
|
* {@code *DatePrecision} is normalized to {@code UNKNOWN} by the service; the
|
||||||
|
* service then enforces coherence (date ⇔ non-UNKNOWN precision) and order
|
||||||
|
* (fromDate ≤ toDate).
|
||||||
|
*/
|
||||||
|
public record RelationshipUpsertRequest(
|
||||||
|
@NotNull UUID relatedPersonId,
|
||||||
|
@NotNull RelationType relationType,
|
||||||
|
LocalDate fromDate,
|
||||||
|
DatePrecision fromDatePrecision,
|
||||||
|
LocalDate toDate,
|
||||||
|
DatePrecision toDatePrecision,
|
||||||
|
@Size(max = 2000) String notes
|
||||||
|
) {}
|
||||||
@@ -28,6 +28,13 @@ import java.util.UUID;
|
|||||||
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||||
* types stay optional.
|
* types stay optional.
|
||||||
*
|
*
|
||||||
|
* <p><b>Letter→event link ({@code linkedEventId}):</b> for a {@link Kind#LETTER} entry, the id of
|
||||||
|
* the curated {@link TimelineEvent} whose {@code documents} set contains the letter's document, or
|
||||||
|
* {@code null} when the letter is referenced by no curated event (#827). Computed on read from the
|
||||||
|
* existing {@code timeline_event_documents} join — no new column. {@code null} for non-letter
|
||||||
|
* entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||||
|
* type stays optional.
|
||||||
|
*
|
||||||
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
||||||
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +54,7 @@ public record TimelineEntryDTO(
|
|||||||
DerivedEventType derivedType,
|
DerivedEventType derivedType,
|
||||||
UUID rootTagId,
|
UUID rootTagId,
|
||||||
String rootTagName,
|
String rootTagName,
|
||||||
String rootTagColor
|
String rootTagColor,
|
||||||
|
UUID linkedEventId
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ public class TimelineEventService {
|
|||||||
p.getBirthDate(), null,
|
p.getBirthDate(), null,
|
||||||
p.getDisplayName(), EventType.PERSONAL,
|
p.getDisplayName(), EventType.PERSONAL,
|
||||||
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
|
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
|
||||||
null, null, null))
|
null, null, null, null))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ public class TimelineEventService {
|
|||||||
p.getDeathDate(), null,
|
p.getDeathDate(), null,
|
||||||
p.getDisplayName(), EventType.PERSONAL,
|
p.getDisplayName(), EventType.PERSONAL,
|
||||||
null, null, List.of(p.getId()), DerivedEventType.DEATH,
|
null, null, List.of(p.getId()), DerivedEventType.DEATH,
|
||||||
null, null, null))
|
null, null, null, null))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,13 +290,11 @@ public class TimelineEventService {
|
|||||||
List<TimelineEntryDTO> result = new ArrayList<>();
|
List<TimelineEntryDTO> result = new ArrayList<>();
|
||||||
for (PersonRelationship r : spouseEdges) {
|
for (PersonRelationship r : spouseEdges) {
|
||||||
if (seen.add(r.getId())) {
|
if (seen.add(r.getId())) {
|
||||||
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
|
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded.
|
||||||
LocalDate eventDate = r.getFromYear() != null
|
// The marriage date is the relationship's from_date at its stored precision
|
||||||
? LocalDate.of(r.getFromYear(), 1, 1)
|
// (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year.
|
||||||
: null;
|
LocalDate eventDate = r.getFromDate();
|
||||||
DatePrecision precision = r.getFromYear() != null
|
DatePrecision precision = r.getFromDatePrecision();
|
||||||
? DatePrecision.YEAR
|
|
||||||
: DatePrecision.UNKNOWN;
|
|
||||||
String title = r.getPerson().getDisplayName()
|
String title = r.getPerson().getDisplayName()
|
||||||
+ " & " + r.getRelatedPerson().getDisplayName();
|
+ " & " + r.getRelatedPerson().getDisplayName();
|
||||||
result.add(new TimelineEntryDTO(
|
result.add(new TimelineEntryDTO(
|
||||||
@@ -306,7 +304,7 @@ public class TimelineEventService {
|
|||||||
null, null,
|
null, null,
|
||||||
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
||||||
DerivedEventType.MARRIAGE,
|
DerivedEventType.MARRIAGE,
|
||||||
null, null, null));
|
null, null, null, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -80,9 +80,14 @@ public class TimelineService {
|
|||||||
// Resolve generation person IDs once — used across all three layers
|
// Resolve generation person IDs once — used across all three layers
|
||||||
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
||||||
|
|
||||||
|
// Fetch curated events once — reused for both the event entries below and the
|
||||||
|
// batched letter→event link resolution (resolveLetterEventLinks), so the
|
||||||
|
// membership pass costs no extra query. REQ-005.
|
||||||
|
List<TimelineEvent> allEvents = eventRepository.findAll();
|
||||||
|
|
||||||
// ── curated events ───────────────────────────────────────────────────
|
// ── curated events ───────────────────────────────────────────────────
|
||||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
List<TimelineEntryDTO> entries = new ArrayList<>();
|
||||||
for (TimelineEvent ev : eventRepository.findAll()) {
|
for (TimelineEvent ev : allEvents) {
|
||||||
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
||||||
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
||||||
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
||||||
@@ -107,8 +112,9 @@ public class TimelineService {
|
|||||||
letters.add(doc);
|
letters.add(doc);
|
||||||
}
|
}
|
||||||
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
|
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
|
||||||
|
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, allEvents);
|
||||||
for (Document doc : letters) {
|
for (Document doc : letters) {
|
||||||
entries.add(mapDocument(doc, rootByDocId));
|
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return bucket(entries);
|
return bucket(entries);
|
||||||
@@ -229,11 +235,13 @@ public class TimelineService {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
|
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
|
||||||
|
Map<UUID, UUID> eventByDocId) {
|
||||||
RootTag root = rootByDocId.get(doc.getId());
|
RootTag root = rootByDocId.get(doc.getId());
|
||||||
return new TimelineEntryDTO(
|
return new TimelineEntryDTO(
|
||||||
Kind.LETTER,
|
Kind.LETTER,
|
||||||
@@ -251,10 +259,38 @@ public class TimelineService {
|
|||||||
null,
|
null,
|
||||||
root == null ? null : root.id(),
|
root == null ? null : root.id(),
|
||||||
root == null ? null : root.name(),
|
root == null ? null : root.name(),
|
||||||
root == null ? null : root.color()
|
root == null ? null : root.color(),
|
||||||
|
eventByDocId.get(doc.getId())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the
|
||||||
|
* event whose {@code documents} set contains the letter (REQ-005). A single doc→event map is
|
||||||
|
* built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents}
|
||||||
|
* carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
|
||||||
|
* event, the first by repository iteration order wins ({@code putIfAbsent}). The map is built
|
||||||
|
* from <em>all</em> events (not just the year/type-filtered ones) so the link is a stable
|
||||||
|
* property of the data; the frontend's filter-then-group decides whether the linked event is
|
||||||
|
* actually on screen (#827). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
|
||||||
|
*/
|
||||||
|
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
|
||||||
|
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
|
||||||
|
if (letterDocIds.isEmpty()) return Map.of();
|
||||||
|
|
||||||
|
Map<UUID, UUID> eventByDocId = new HashMap<>();
|
||||||
|
for (TimelineEvent ev : events) {
|
||||||
|
Set<Document> linkedDocs = ev.getDocuments();
|
||||||
|
if (linkedDocs == null) continue;
|
||||||
|
for (Document linked : linkedDocs) {
|
||||||
|
if (letterDocIds.contains(linked.getId())) {
|
||||||
|
eventByDocId.putIfAbsent(linked.getId(), ev.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventByDocId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
|
* 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),
|
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- V78: person_relationships.from_year/to_year (integer) → from_date/to_date (date)
|
||||||
|
-- plus NOT NULL precision columns, mirroring persons.{birth,death}_date (V76 / ADR-039).
|
||||||
|
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-044).
|
||||||
|
-- One-way migration: rollback is a targeted pg_restore -t person_relationships from
|
||||||
|
-- the pre-deploy backup (see docs/DEPLOYMENT.md). The column drop is NOT
|
||||||
|
-- rolling-deploy-safe — stop the old JAR before running this migration.
|
||||||
|
|
||||||
|
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
|
||||||
|
-- before any DDL runs. Single-writer family archive, so no race window matters.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year)
|
||||||
|
THEN RAISE EXCEPTION 'V78 aborted: % relationships have from_year > to_year — fix data before migrating',
|
||||||
|
(SELECT COUNT(*) FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year);
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year = 0 OR to_year = 0)
|
||||||
|
THEN RAISE EXCEPTION 'V78 aborted: person_relationships table contains from_year=0 or to_year=0 rows — clean data before migrating';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN from_date date;
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN from_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN to_date date;
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN to_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||||
|
|
||||||
|
UPDATE person_relationships SET from_date = make_date(from_year, 1, 1), from_date_precision = 'YEAR'
|
||||||
|
WHERE from_year IS NOT NULL;
|
||||||
|
UPDATE person_relationships SET to_date = make_date(to_year, 1, 1), to_date_precision = 'YEAR'
|
||||||
|
WHERE to_year IS NOT NULL;
|
||||||
|
|
||||||
|
-- Named constraints: readable Postgres error messages when violated.
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_coherence
|
||||||
|
CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_coherence
|
||||||
|
CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_date_order
|
||||||
|
CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date);
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_precision_values
|
||||||
|
CHECK (from_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_precision_values
|
||||||
|
CHECK (to_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||||
|
|
||||||
|
ALTER TABLE person_relationships DROP COLUMN from_year;
|
||||||
|
ALTER TABLE person_relationships DROP COLUMN to_year;
|
||||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
import org.mockito.InOrder;
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
@@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
RelationshipDTO edge = new RelationshipDTO(
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
UUID.randomUUID(), parentId, childId,
|
UUID.randomUUID(), parentId, childId,
|
||||||
"Parent", null, null, "Child", null, null,
|
"Parent", null, null, "Child", null, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
|
|||||||
new PersonTreeImporter(personService, relationshipService)
|
new PersonTreeImporter(personService, relationshipService)
|
||||||
.load(json.toFile());
|
.load(json.toFile());
|
||||||
|
|
||||||
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
|
||||||
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
||||||
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
||||||
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -25,6 +26,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doNothing;
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -98,7 +101,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", 1900, 1980,
|
"Alice Müller", 1900, 1980,
|
||||||
"Bob Müller", 1930, null,
|
"Bob Müller", 1930, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", null, null,
|
"Alice Müller", null, null,
|
||||||
"Bob Müller", null, null,
|
"Bob Müller", null, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
@@ -158,4 +161,51 @@ class RelationshipControllerTest {
|
|||||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PUT /api/persons/{id}/relationships/{relId} ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
RelationshipDTO updated = new RelationshipDTO(
|
||||||
|
relId, PERSON_ID, OTHER_ID,
|
||||||
|
"Alice Müller", null, null,
|
||||||
|
"Bob Müller", null, null,
|
||||||
|
RelationType.SPOUSE_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
|
when(relationshipService.updateRelationship(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.relationType").value("SPOUSE_OF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
verify(relationshipService, never()).updateRelationship(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void updateRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void updateRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies V78: person_relationships.from_year/to_year (integer) become
|
||||||
|
* from_date/to_date (date) + *_date_precision columns, with backfill to
|
||||||
|
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
|
||||||
|
* pre-check that aborts the migration on corrupt year data. Mirrors
|
||||||
|
* {@code PersonBirthDeathMigrationTest} (V76 / ADR-039).
|
||||||
|
*
|
||||||
|
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
|
||||||
|
* database so the staged migrate-to-V77 → seed → migrate-to-latest flow and
|
||||||
|
* the abort cases cannot interfere with each other. Uses a real Postgres
|
||||||
|
* container — H2 does not honour CHECK constraints.
|
||||||
|
*/
|
||||||
|
class RelationshipMigrationTest {
|
||||||
|
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||||
|
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
|
||||||
|
|
||||||
|
private String dbUrl;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startContainer() {
|
||||||
|
POSTGRES.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopContainer() {
|
||||||
|
POSTGRES.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void createFreshDatabase() throws SQLException {
|
||||||
|
String dbName = "mig_v78_" + DB_COUNTER.incrementAndGet();
|
||||||
|
try (Connection conn = DriverManager.getConnection(
|
||||||
|
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE DATABASE " + dbName);
|
||||||
|
}
|
||||||
|
dbUrl = baseUrl(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void precheck_abortsWhenFromYearAfterToYear() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1958, 1923);
|
||||||
|
|
||||||
|
assertThatThrownBy(this::migrateToLatest)
|
||||||
|
.hasMessageContaining("V78 aborted")
|
||||||
|
.hasMessageContaining("from_year > to_year");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void precheck_abortsWhenYearZeroPresent() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "FRIEND", 0, null);
|
||||||
|
|
||||||
|
assertThatThrownBy(this::migrateToLatest)
|
||||||
|
.hasMessageContaining("V78 aborted")
|
||||||
|
.hasMessageContaining("from_year=0 or to_year=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_fromYearAndToYear_becomeYearPrecisionDates() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
RelationDates row = relationDates(a, b, "SPOUSE_OF");
|
||||||
|
assertThat(row.fromDate()).hasToString("1923-01-01");
|
||||||
|
assertThat(row.fromPrecision()).isEqualTo("YEAR");
|
||||||
|
assertThat(row.toDate()).hasToString("1958-01-01");
|
||||||
|
assertThat(row.toPrecision()).isEqualTo("YEAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "FRIEND", null, null);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
RelationDates row = relationDates(a, b, "FRIEND");
|
||||||
|
assertThat(row.fromDate()).isNull();
|
||||||
|
assertThat(row.fromPrecision()).isEqualTo("UNKNOWN");
|
||||||
|
assertThat(row.toDate()).isNull();
|
||||||
|
assertThat(row.toPrecision()).isEqualTo("UNKNOWN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_preservesRowCount() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
UUID c = seedPerson("Gamma");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
seedRelationship(a, c, "FRIEND", null, null);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThat(countWhere("1 = 1")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderCheckConstraint_rejectsToDateBeforeFromDate() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> insertDatedRelationship(
|
||||||
|
a, b, "FRIEND", "1958-01-01", "YEAR", "1923-01-01", "YEAR"))
|
||||||
|
.hasMessageContaining("chk_relationship_date_order");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> insertDatedRelationship(
|
||||||
|
a, b, "FRIEND", "1923-01-01", "UNKNOWN", null, "UNKNOWN"))
|
||||||
|
.hasMessageContaining("chk_relationship_from_coherence");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThat(columnExists("from_year")).isFalse();
|
||||||
|
assertThat(columnExists("to_year")).isFalse();
|
||||||
|
assertThat(columnExists("from_date")).isTrue();
|
||||||
|
assertThat(columnExists("to_date")).isTrue();
|
||||||
|
for (String constraint : new String[]{
|
||||||
|
"chk_relationship_from_coherence",
|
||||||
|
"chk_relationship_to_coherence",
|
||||||
|
"chk_relationship_date_order",
|
||||||
|
"chk_relationship_from_precision_values",
|
||||||
|
"chk_relationship_to_precision_values"}) {
|
||||||
|
assertThat(constraintExists(constraint)).as(constraint).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private static String baseUrl(String dbName) {
|
||||||
|
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateTo(String targetVersion) {
|
||||||
|
flywayBuilder().target(targetVersion).load().migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateToLatest() {
|
||||||
|
flywayBuilder().load().migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
|
||||||
|
return Flyway.configure()
|
||||||
|
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.placeholders(Map.of("grafanaDbPassword", "test-only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID seedPerson(String lastName) throws SQLException {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO persons (id, last_name, person_type, family_member, provisional) "
|
||||||
|
+ "VALUES (?, ?, 'PERSON', false, false)")) {
|
||||||
|
stmt.setObject(1, id);
|
||||||
|
stmt.setString(2, lastName);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedRelationship(UUID personId, UUID relatedId, String type, Integer fromYear, Integer toYear)
|
||||||
|
throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO person_relationships (id, person_id, related_person_id, relation_type, from_year, to_year) "
|
||||||
|
+ "VALUES (gen_random_uuid(), ?, ?, ?, ?, ?)")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
stmt.setObject(4, fromYear);
|
||||||
|
stmt.setObject(5, toYear);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertDatedRelationship(UUID personId, UUID relatedId, String type,
|
||||||
|
String fromDate, String fromPrecision,
|
||||||
|
String toDate, String toPrecision) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO person_relationships "
|
||||||
|
+ "(id, person_id, related_person_id, relation_type, from_date, from_date_precision, to_date, to_date_precision) "
|
||||||
|
+ "VALUES (gen_random_uuid(), ?, ?, ?, CAST(? AS date), ?, CAST(? AS date), ?)")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
stmt.setObject(4, fromDate);
|
||||||
|
stmt.setString(5, fromPrecision);
|
||||||
|
stmt.setObject(6, toDate);
|
||||||
|
stmt.setString(7, toPrecision);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RelationDates(Object fromDate, String fromPrecision, Object toDate, String toPrecision) {}
|
||||||
|
|
||||||
|
private RelationDates relationDates(UUID personId, UUID relatedId, String type) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT from_date, from_date_precision, to_date, to_date_precision "
|
||||||
|
+ "FROM person_relationships WHERE person_id = ? AND related_person_id = ? AND relation_type = ?")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
assertThat(rs.next()).as("relationship exists").isTrue();
|
||||||
|
return new RelationDates(
|
||||||
|
rs.getObject("from_date"),
|
||||||
|
rs.getString("from_date_precision"),
|
||||||
|
rs.getObject("to_date"),
|
||||||
|
rs.getString("to_date_precision"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countWhere(String condition) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM person_relationships WHERE " + condition)) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean columnExists(String columnName) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
+ "WHERE table_schema = 'public' AND table_name = 'person_relationships' AND column_name = ?")) {
|
||||||
|
stmt.setString(1, columnName);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean constraintExists(String constraintName) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
|
||||||
|
stmt.setString(1, constraintName);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Connection connect() throws SQLException {
|
||||||
|
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
||||||
@@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import;
|
|||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_stores_and_is_readable() {
|
void addRelationship_stores_and_is_readable() {
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||||
|
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null);
|
||||||
|
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(created.id()).isNotNull();
|
assertThat(created.id()).isNotNull();
|
||||||
assertThat(created.personId()).isEqualTo(alice.getId());
|
assertThat(created.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
assertThat(created.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||||
|
assertThat(created.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||||
|
assertThat(created.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
|
|
||||||
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
||||||
assertThat(rels).hasSize(1);
|
assertThat(rels).hasSize(1);
|
||||||
@@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_409_when_duplicate() {
|
void addRelationship_throws_409_when_duplicate() {
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
relationshipService.addRelationship(alice.getId(), dto);
|
relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||||
@@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void addRelationship_throws_409_when_circular_parent() {
|
void addRelationship_throws_409_when_circular_parent() {
|
||||||
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -103,28 +109,58 @@ class RelationshipServiceIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
void deleteRelationship_throws_404_when_rel_belongs_to_different_person() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
// Charlie is unrelated to this row.
|
// Charlie is unrelated to this row. Ownership mismatch is 404, not 403, so a
|
||||||
|
// curator cannot enumerate relationship ids belonging to people they can't see
|
||||||
|
// (anti-enumeration; aligned with the PUT endpoint — ADR-044).
|
||||||
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
|
||||||
// The row is still there.
|
// The row is still there.
|
||||||
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_persists_new_type_dates_and_notes() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null));
|
||||||
|
|
||||||
|
RelationshipDTO updated = relationshipService.updateRelationship(alice.getId(), created.id(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
|
||||||
|
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day"));
|
||||||
|
|
||||||
|
assertThat(updated.id()).isEqualTo(created.id());
|
||||||
|
assertThat(updated.relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
assertThat(updated.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
||||||
|
assertThat(updated.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
assertThat(updated.notes()).isEqualTo("wedding day");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_404_when_rel_belongs_to_different_person() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> relationshipService.updateRelationship(charlie.getId(), created.id(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null)))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
||||||
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
||||||
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||||
|
|
||||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -135,7 +171,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||||
// alice SPOUSE_OF bob. Bob deletes from his side.
|
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||||
|
|
||||||
relationshipService.deleteRelationship(bob.getId(), created.id());
|
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||||
|
|
||||||
@@ -148,7 +184,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
||||||
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
relationshipService.setFamilyMember(charlie.getId(), false);
|
relationshipService.setFamilyMember(charlie.getId(), false);
|
||||||
|
|
||||||
NetworkDTO before = relationshipService.getFamilyNetwork();
|
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||||
@@ -165,7 +201,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void delete_person_cascades_to_relationships() {
|
void delete_person_cascades_to_relationships() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
UUID relId = created.id();
|
UUID relId = created.id();
|
||||||
assertThat(relationshipRepository.findById(relId)).isPresent();
|
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -59,9 +61,9 @@ class RelationshipServiceTest {
|
|||||||
charlie = person("Charlie");
|
charlie = person("Charlie");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Nora blocker 1 ---
|
// --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
|
||||||
@Test
|
@Test
|
||||||
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
|
void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
|
||||||
UUID relId = UUID.randomUUID();
|
UUID relId = UUID.randomUUID();
|
||||||
PersonRelationship rel = parentOf(alice, bob, relId);
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
@@ -69,7 +71,7 @@ class RelationshipServiceTest {
|
|||||||
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
verify(relationshipRepository, never()).delete(any());
|
verify(relationshipRepository, never()).delete(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ class RelationshipServiceTest {
|
|||||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -98,7 +100,7 @@ class RelationshipServiceTest {
|
|||||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -107,7 +109,7 @@ class RelationshipServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -116,14 +118,42 @@ class RelationshipServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
||||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
|
||||||
|
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.UNKNOWN, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
null, DatePrecision.DAY, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +170,16 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||||
|
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
|
||||||
var result = service.addRelationship(alice.getId(), dto);
|
var result = service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(result.personId()).isEqualTo(alice.getId());
|
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
assertThat(result.fromYear()).isEqualTo(1900);
|
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||||
|
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||||
|
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
assertThat(result.notes()).isEqualTo("first born");
|
assertThat(result.notes()).isEqualTo("first born");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +199,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService).setFamilyMember(alice.getId(), true);
|
verify(personService).setFamilyMember(alice.getId(), true);
|
||||||
@@ -187,7 +220,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
||||||
@@ -216,6 +249,131 @@ class RelationshipServiceTest {
|
|||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- updateRelationship (REQ-004/006/007/008/009/010/013) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_NOT_FOUND_when_relId_unknown() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(charlie.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_VALIDATION_ERROR_on_self_relation() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
|
||||||
|
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_DUPLICATE_when_db_constraint_violated() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_updates_fields_and_returns_dto() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
|
||||||
|
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day");
|
||||||
|
var result = service.updateRelationship(alice.getId(), relId, dto);
|
||||||
|
|
||||||
|
assertThat(result.relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
||||||
|
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
|
assertThat(result.notes()).isEqualTo("wedding day");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_marks_both_endpoints_family_when_updated_to_family_type() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SIBLING_OF, null, null, null, null, null);
|
||||||
|
service.updateRelationship(alice.getId(), relId, dto);
|
||||||
|
|
||||||
|
verify(personService).setFamilyMember(alice.getId(), true);
|
||||||
|
verify(personService).setFamilyMember(bob.getId(), true);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
||||||
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
||||||
@@ -260,11 +418,15 @@ class RelationshipServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
||||||
|
return relOf(parent, child, RelationType.PARENT_OF, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship relOf(Person subject, Person object, RelationType type, UUID id) {
|
||||||
return PersonRelationship.builder()
|
return PersonRelationship.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.person(parent)
|
.person(subject)
|
||||||
.relatedPerson(child)
|
.relatedPerson(object)
|
||||||
.relationType(RelationType.PARENT_OF)
|
.relationType(type)
|
||||||
.createdAt(Instant.now())
|
.createdAt(Instant.now())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,12 +81,19 @@ class DerivedEventsAssemblyTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
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()
|
return PersonRelationship.builder()
|
||||||
.id(UUID.randomUUID())
|
.id(UUID.randomUUID())
|
||||||
.person(a)
|
.person(a)
|
||||||
.relatedPerson(b)
|
.relatedPerson(b)
|
||||||
.relationType(RelationType.SPOUSE_OF)
|
.relationType(RelationType.SPOUSE_OF)
|
||||||
.fromYear(fromYear)
|
.fromDate(fromDate)
|
||||||
|
.fromDatePrecision(precision)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +230,24 @@ class DerivedEventsAssemblyTest {
|
|||||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
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) ---
|
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ class TimelineServiceTest {
|
|||||||
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||||
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
|
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
|
||||||
null, null, null);
|
null, null, null, null);
|
||||||
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
|
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
|
||||||
null, null, null);
|
null, null, null, null);
|
||||||
|
|
||||||
var sorted = List.of(e2, e1).stream()
|
var sorted = List.of(e2, e1).stream()
|
||||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||||
@@ -511,6 +511,44 @@ class TimelineServiceTest {
|
|||||||
verify(tagService, times(1)).resolveRootTags(anyList());
|
verify(tagService, times(1)).resolveRootTags(anyList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── letter→event link (#827, REQ-005/006) ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letter_in_a_curated_events_documents_carries_that_events_id() {
|
||||||
|
// REQ-005: linkedEventId = the curated event whose documents set contains the letter.
|
||||||
|
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||||
|
UUID eventId = UUID.randomUUID();
|
||||||
|
TimelineEvent event = TimelineEvent.builder().id(eventId)
|
||||||
|
.title("Briefe von der Front").type(EventType.PERSONAL)
|
||||||
|
.documents(new HashSet<>(Set.of(letterDoc)))
|
||||||
|
.build(); // no eventDate → event lands undated, leaving the year band to the letter
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
|
||||||
|
assertThat(entry.linkedEventId()).isEqualTo(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letter_in_no_curated_event_has_null_linkedEventId() {
|
||||||
|
// REQ-006: a letter referenced by no curated event → linkedEventId null (frontend falls
|
||||||
|
// back to the per-year "Weitere Briefe" bucket).
|
||||||
|
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||||
|
TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
|
||||||
|
.title("Anderes Ereignis").type(EventType.PERSONAL)
|
||||||
|
.documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build())))
|
||||||
|
.build();
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
|
||||||
|
assertThat(entry.linkedEventId()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
|
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
|
||||||
assertThat(result.years()).hasSize(1);
|
assertThat(result.years()).hasSize(1);
|
||||||
return result.years().get(0).entries().get(0);
|
return result.years().get(0).entries().get(0);
|
||||||
@@ -523,7 +561,7 @@ class TimelineServiceTest {
|
|||||||
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
|
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
|
||||||
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
|
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
|
||||||
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
|
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
|
||||||
null, null, null);
|
null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
||||||
|
|||||||
@@ -538,6 +538,29 @@ pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
|||||||
|
|
||||||
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
||||||
|
|
||||||
|
### Deploy note — V78 (person_relationships from/to → date + precision, #837)
|
||||||
|
|
||||||
|
V78 drops `person_relationships.from_year`/`to_year` after backfilling the new
|
||||||
|
`from_date`/`to_date` + precision columns — a **one-way migration** (Flyway cannot roll
|
||||||
|
it back). Like V76 it runs its pre-check + DDL in one atomic Flyway transaction and
|
||||||
|
needs **no maintenance window** (single-writer archive, no concurrent importers).
|
||||||
|
|
||||||
|
It is, however, **not rolling-deploy-safe**: the previously-running JAR still maps the
|
||||||
|
`from_year`/`to_year` columns, so it would error against the migrated schema. Deploy in
|
||||||
|
this order (the default stop-then-start, single-instance deploy already satisfies it):
|
||||||
|
|
||||||
|
1. Take a manual `pg_dump` (see above) and confirm it completed.
|
||||||
|
2. **Stop the old JAR**, then **start the new JAR** — Flyway V78 runs first thing on the
|
||||||
|
new JAR's startup, before any request is served. Never run the old and new JARs
|
||||||
|
concurrently across this migration.
|
||||||
|
|
||||||
|
If post-deploy data issues are found, restore **only the person_relationships table**
|
||||||
|
from the pre-migration dump:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_restore -t person_relationships -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
||||||
|
```
|
||||||
|
|
||||||
### Rollback
|
### Rollback
|
||||||
|
|
||||||
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
||||||
|
|||||||
91
docs/adr/044-relationship-dates-localdate-precision.md
Normal file
91
docs/adr/044-relationship-dates-localdate-precision.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# ADR-044 — Relationship dates become LocalDate + DatePrecision; relationships become editable
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-14
|
||||||
|
**Issue:** #837 (Zeitstrahl milestone; deferred follow-up to #773 / ADR-039)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`PersonRelationship` stored its span as `Integer fromYear`/`toYear`. A wedding could
|
||||||
|
never be more precise than `1923`, while `Person` (ADR-039), `Document`, and
|
||||||
|
`TimelineEvent` already carry full `DatePrecision`. Relationships also supported only
|
||||||
|
create + delete: fixing a wrong type, a wrong person, or adding a date learned later
|
||||||
|
meant deleting and re-creating the edge — losing `createdAt`. A `notes` column existed
|
||||||
|
that no form set and nothing displayed.
|
||||||
|
|
||||||
|
V78 replaces the two integer columns with `from_date`/`to_date` (`DATE`, nullable) plus
|
||||||
|
`from_date_precision`/`to_date_precision` (`VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'`),
|
||||||
|
backfilling existing years as `YYYY-01-01` at `YEAR` precision — exactly the V76 / ADR-039
|
||||||
|
pattern applied to the relationship edge. A new `PUT /api/persons/{id}/relationships/{relId}`
|
||||||
|
makes relationships editable, and `notes` is activated end to end.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Mirror ADR-039 verbatim for the relationship edge
|
||||||
|
|
||||||
|
`DatePrecision` is imported cross-domain from `document/` (ADR-039 §1 — value-type
|
||||||
|
sharing, not a layering breach). The precision columns are NOT NULL default `UNKNOWN`,
|
||||||
|
guarded by five named CHECK constraints (`chk_relationship_from_coherence`,
|
||||||
|
`chk_relationship_to_coherence`, `chk_relationship_date_order`,
|
||||||
|
`chk_relationship_{from,to}_precision_values`). `RelationshipService.validateRelationshipDates`
|
||||||
|
enforces the same rules first, so the user gets a structured 400
|
||||||
|
(`INVALID_DATE_PRECISION` for coherence, the new `INVALID_RELATIONSHIP_DATES` for a
|
||||||
|
`toDate < fromDate` order violation) instead of a constraint-violation 500. The form
|
||||||
|
offers only **DAY / MONTH / YEAR**; storage still accepts all seven values, and a
|
||||||
|
stored non-offered precision seeds the edit select as `YEAR` (ADR-039 §2).
|
||||||
|
|
||||||
|
### 2. Update re-runs every create invariant
|
||||||
|
|
||||||
|
An edit can violate the same invariants as a create, so `updateRelationship` re-runs
|
||||||
|
all of them: self-relation (`VALIDATION_ERROR`), date coherence + order, reverse
|
||||||
|
`PARENT_OF` (`CIRCULAR_RELATIONSHIP`), and the `(person, relatedPerson, type)` unique
|
||||||
|
constraint via `saveAndFlush` (`DUPLICATE_RELATIONSHIP`). Editing into a family type
|
||||||
|
flags both endpoints as family members (additive; never auto-unflags). The directed
|
||||||
|
orientation is preserved per viewpoint — whichever endpoint `{personId}` already holds
|
||||||
|
on the row stays put — so a `PARENT_OF` edge remains parent→child whether edited from
|
||||||
|
either person's page.
|
||||||
|
|
||||||
|
### 3. No optimistic locking (`@Version`)
|
||||||
|
|
||||||
|
`PersonRelationship` gains no `@Version`; the edit is last-write-wins, matching the
|
||||||
|
person edit form. This is a single-writer family archive, and it avoids the managed-
|
||||||
|
`setVersion` pitfall (a `setVersion` on a managed entity is silently ignored by
|
||||||
|
Hibernate — see the integration-test note in #496-era work). If concurrent curation
|
||||||
|
ever becomes real, add `@Version` plus an explicit client-version compare then.
|
||||||
|
|
||||||
|
### 4. IDOR / anti-enumeration: ownership mismatch is 404, for PUT **and** DELETE
|
||||||
|
|
||||||
|
A `{relId}` that does not belong to `{personId}` returns 404 `RELATIONSHIP_NOT_FOUND`
|
||||||
|
(a shared `loadOwnedRelationship` helper), so a curator cannot probe relationship ids
|
||||||
|
belonging to people they cannot see. This **aligns `deleteRelationship`** from its
|
||||||
|
former 403 to 404 in the same change, so the two mutating endpoints behave identically
|
||||||
|
on the same mismatch.
|
||||||
|
|
||||||
|
### 5. Derived marriage events gain precision for free
|
||||||
|
|
||||||
|
`TimelineEventService.buildMarriageEvents` now sources the Heirat date from the
|
||||||
|
`SPOUSE_OF` row's `from_date` + `from_date_precision` (previously
|
||||||
|
`LocalDate.of(fromYear, 1, 1)` at hard-coded `YEAR`). A DAY-precision wedding now
|
||||||
|
surfaces the exact day on the Zeitstrahl. `RelationshipInferenceService` is unchanged
|
||||||
|
— it is time-ignorant and never read the year fields.
|
||||||
|
|
||||||
|
### 6. `relationshipDates.ts` lives in `$lib/person/`, no new boundary
|
||||||
|
|
||||||
|
`formatRelationshipDateRange` mirrors `personLifeDates.ts` and delegates entirely to the
|
||||||
|
already-tested `formatDocumentDate` (zero new precision logic). It sits in `$lib/person/`
|
||||||
|
next to `personLifeDates.ts`; its only cross-domain import is `formatDocumentDate` from
|
||||||
|
`$lib/shared/utils/`, which the existing `person → shared` rule in `eslint.config.js`
|
||||||
|
already permits — **no new eslint boundary rule is added**.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- V78 is one-way (columns dropped) and is **not** rolling-deploy-safe — the running JAR
|
||||||
|
maps `from_year` until redeploy. Deploy order: **stop old JAR → run Flyway V78 →
|
||||||
|
start new JAR**. Rollback = targeted `pg_restore -t person_relationships` from the
|
||||||
|
pre-deploy dump (see `docs/DEPLOYMENT.md` §8). No maintenance window needed
|
||||||
|
(single-writer archive).
|
||||||
|
- Relationships are fully editable (type, related person, dates, notes) and the read
|
||||||
|
view shows the date range + notes.
|
||||||
|
- `RelationshipDTO` drops `fromYear`/`toYear` for `fromDate`/`fromDatePrecision`/
|
||||||
|
`toDate`/`toDatePrecision`; the `personBirthYear`/`relatedPersonBirthYear` derived
|
||||||
|
fields are unaffected (ADR-039 §3).
|
||||||
78
docs/adr/045-timeline-client-side-regroup.md
Normal file
78
docs/adr/045-timeline-client-side-regroup.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR-045 — The /zeitstrahl Ereignis/Thema regroup is client-side, over a computed letter→event link
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-15
|
||||||
|
**Issue:** #827 (Zeitstrahl milestone; deferred follow-up to #779, builds on #835/PR #838 and #780)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
#779 shipped `/zeitstrahl` in **Datum** mode only and deferred the Concept-A
|
||||||
|
**Datum · Ereignis · Thema** segmented control, because the other two modes need data the
|
||||||
|
`TimelineEntryDTO` did not carry: a letter's curated-event association (Ereignis) and a letter's
|
||||||
|
primary root tag + colour (Thema). #835 (merged in PR #838) added the Thema fields
|
||||||
|
(`rootTagId`/`rootTagName`/`rootTagColor`) and the batched `TimelineService → TagService`
|
||||||
|
resolver. Meanwhile #780 added the **layer filter** — `/zeitstrahl/+page.svelte` owns
|
||||||
|
`personalOn`/`historicalOn`/`lettersOn` `$state` and renders `TimelineView` over a client-side
|
||||||
|
`filterTimeline(data.timeline, …)` view.
|
||||||
|
|
||||||
|
This ADR records the three forks specific to **#827** (the Thema enrichment + the
|
||||||
|
`TimelineService → TagService` edge are #835's scope, not this one).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Grouping is a client-side presentation transform — no `grouping=` query param
|
||||||
|
|
||||||
|
`GET /api/timeline` already returns the whole timeline in one payload. Regrouping the loose
|
||||||
|
letters is an in-memory transform in `lib/timeline/timelineGrouping.ts` (`bucketLetters`,
|
||||||
|
`buildEventLookup`, `hasLooseLetters`), driven by a `groupingMode` `$state` in `+page.svelte`.
|
||||||
|
A server-side `grouping=DATE|EVENT|TOPIC` parameter was rejected: it would add lasting API
|
||||||
|
surface and a bucket query for zero benefit on an already-loaded payload, and switching modes
|
||||||
|
must issue **zero** extra fetches (REQ-002). The blast radius stays inside the read view.
|
||||||
|
|
||||||
|
### 2. The letter→event link is computed, reusing `timeline_event_documents` — no new column
|
||||||
|
|
||||||
|
A letter clusters under a curated event iff that event's `documents` set (ADR-040;
|
||||||
|
`@ManyToMany @BatchSize(50)` over join table `timeline_event_documents`) contains the letter's
|
||||||
|
document. `TimelineService.assemble` resolves this in **one batched membership pass** —
|
||||||
|
`resolveLetterEventLinks` builds a single `docId → eventId` map over the already-loaded events
|
||||||
|
(no per-letter query), reusing the same `eventRepository.findAll()` it already iterates for the
|
||||||
|
event entries. The result is exposed as one nullable DTO field, `linkedEventId`. A new persisted
|
||||||
|
FK on the document/letter row was rejected: it duplicates an existing capability and opens a
|
||||||
|
mutating write path + Flyway migration for no gain. **No new column, no migration, no new
|
||||||
|
cross-domain edge** (the field derives from data `TimelineService` already loads). `linkedEventId`
|
||||||
|
is deliberately **not** `@Schema(requiredMode = REQUIRED)` — it is null for non-letter entries and
|
||||||
|
for letters under no curated event — so the generated TypeScript type stays optional.
|
||||||
|
|
||||||
|
### 3. Grouping composes with the #780 layer filter as **filter-then-group**
|
||||||
|
|
||||||
|
The pipeline is `data.timeline → filterTimeline() (#780) → groupingMode transform → TimelineView`.
|
||||||
|
The grouping `$state` lives in `+page.svelte` beside the filter `$state`, and the regroup runs over
|
||||||
|
the layer-**filtered** view, never the raw `data.timeline`. Grouping the raw timeline and filtering
|
||||||
|
afterward was rejected: the counts and buckets would disagree with the layer toggles, re-opening
|
||||||
|
the #780 count-mismatch the page already closed. Two consequences fall out of filter-then-group:
|
||||||
|
|
||||||
|
- **Letters layer off → the grouping control disables, kept in place (REQ-018).** With no loose
|
||||||
|
letters in the filtered view there is nothing to regroup; the control renders `aria-disabled`
|
||||||
|
(no header reflow), keeps its selected mode, and announces a screen-reader reason.
|
||||||
|
- **A letter whose only linking event was filtered out falls back to "Weitere Briefe" (REQ-019).**
|
||||||
|
`buildEventLookup` is built from the events present in the _filtered_ view, so Ereignis clusters
|
||||||
|
only under events that survived the filter; everything else lands in the per-year fallback bucket.
|
||||||
|
|
||||||
|
The control is a `role="radiogroup"` (single-select), deliberately distinct from #780's
|
||||||
|
`aria-pressed` toggle filter, stacked above the filter trigger so the two read as one control
|
||||||
|
cluster — the top-right corner stays the #842 add-event CTA.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One nullable field (`linkedEventId`) is added to `TimelineEntryDTO` (17 components); the
|
||||||
|
regenerated `frontend/src/lib/generated/api.ts` is committed in the same PR. No table, column,
|
||||||
|
Flyway migration, endpoint, `ErrorCode`, or `Permission` changes.
|
||||||
|
- The regroup is pure and fully unit-tested independently of the components; `TimelineView`/
|
||||||
|
`YearBand` render the axis-fixed event layer identically across all three modes (REQ-001) and
|
||||||
|
only swap the loose-letter rendering for per-year `LetterBucket`s off Datum.
|
||||||
|
- The new Thema bucket-header chip (`BucketHeaderChip`) is a filled variant tinted from
|
||||||
|
`rootTagColor`; the shipped neutral per-letter `TagChip` (#838) is reused as-is and suppressed
|
||||||
|
inside its own bucket (REQ-017). All `lib/timeline` components keep the `{...}`-escaping
|
||||||
|
guarantee — a grep gate forbids `{@html}` (REQ-009).
|
||||||
|
- Read-only feature: no new authn/authz surface beyond the existing `READ_ALL` on
|
||||||
|
`GET /api/timeline`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@startuml db-orm
|
@startuml db-orm
|
||||||
' Schema source: Flyway V1–V77 (excl. V37, V43 — intentionally removed)
|
' Schema source: Flyway V1–V78 (excl. V37, V43 — intentionally removed)
|
||||||
' Schema as of: V77 (2026-06-12)
|
' Schema as of: V78 (2026-06-14)
|
||||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||||
|
|
||||||
hide circle
|
hide circle
|
||||||
@@ -211,8 +211,10 @@ package "Persons" {
|
|||||||
person_id : UUID <<FK>>
|
person_id : UUID <<FK>>
|
||||||
related_person_id : UUID <<FK>>
|
related_person_id : UUID <<FK>>
|
||||||
relation_type : VARCHAR(30) NOT NULL
|
relation_type : VARCHAR(30) NOT NULL
|
||||||
from_year : INTEGER
|
from_date : DATE
|
||||||
to_year : INTEGER
|
from_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
|
||||||
|
to_date : DATE
|
||||||
|
to_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
|
||||||
notes : VARCHAR(2000)
|
notes : VARCHAR(2000)
|
||||||
created_at : TIMESTAMPTZ NOT NULL
|
created_at : TIMESTAMPTZ NOT NULL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
|
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
|
||||||
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
||||||
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
|
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
|
||||||
|
' Note: V78 swaps person_relationships.from_year/to_year for from_date/to_date +
|
||||||
|
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
||||||
|
|
||||||
hide circle
|
hide circle
|
||||||
skinparam linetype ortho
|
skinparam linetype ortho
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
# Zeitstrahl grouped-view contained-card layout — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the grouped-view's full-width bucket blocks with self-contained cards — a cluster (event or root tag) becomes one bordered card whose header is the event/tag and whose body shows the first 5 letters with a show-more toggle; the leftover bin collapses to a count-only drawer; a same-year curated event renders *as* its card header (no duplicate pill).
|
||||||
|
|
||||||
|
**Architecture:** Frontend-only, on branch `feat/issue-827-zeitstrahl-grouping` (worktree `.worktrees/issue-827-zeitstrahl-grouping`). Evolve `LetterBucket.svelte` into the contained card (preview cap + show-more + collapsed drawer + card chrome + optional event header) and rewire `YearBand.svelte`'s Ereignis branch so a same-year curated event becomes the card header instead of a separate pill. Datum mode is untouched. Design doc: `docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md`.
|
||||||
|
|
||||||
|
**Tech Stack:** Svelte 5 (runes), Tailwind 4, Paraglide i18n, Vitest browser mode (`--project=client` for `*.svelte.spec.ts`, `--project=server` for plain `*.spec.ts`).
|
||||||
|
|
||||||
|
**Conventions (read before starting):**
|
||||||
|
- Red→green TDD, one logical change per commit, `Refs #827` on the last body line.
|
||||||
|
- Run only the specific spec file(s) — never the full suite. Client: `npx vitest run <file> --project=client`; server: `... --project=server`.
|
||||||
|
- Before each commit: `npx prettier --write <changed files>`, then `git add <files>` + `git diff --cached --stat` to verify the staged set, then commit (the pre-commit hook runs `prettier --check` + `eslint`).
|
||||||
|
- **No `new Set()`/`new Map()` inside a `.svelte` file** — `svelte/prefer-svelte-reactivity` errors even on transient locals. Use plain arrays (`find`/`some`/`filter`) inside `$derived`. (Pure `.ts` modules are fine.)
|
||||||
|
- Prettier rewrites `class:foo` shorthand to `class:foo={foo}` — expect that.
|
||||||
|
- Factories in `frontend/src/lib/timeline/test-factories.ts`: `makeEntry`, `makeYear`, `makeTimelineDTO`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `frontend/src/lib/timeline/timelineGrouping.ts` — add `CLUSTER_PREVIEW = 5`; remove `BUCKET_DENSE_THRESHOLD`/`isBucketDense` (no longer used). Keep `bucketLetters`, `buildEventLookup`, `hasLooseLetters`, `tagColorVar`.
|
||||||
|
- `frontend/src/lib/timeline/LetterBucket.svelte` — the contained card: card chrome + colour rail + header variants (tag chip / event-header / cross-year text label / drawer label) + body (first-5 preview, show-more toggle, drawer collapsed-by-default). Drop the `YearLetterStrip` branch.
|
||||||
|
- `frontend/src/lib/timeline/YearBand.svelte` — Ereignis branch: a same-year curated event renders as a `LetterBucket` card with `event={entry}` (no separate pill); letterless/derived/world events stay plain; cross-year clusters + the fallback drawer render after the axis entries.
|
||||||
|
- `frontend/messages/{de,en,es}.json` — two new keys: `timeline_bucket_show_more` (`{count}`), `timeline_bucket_show_less`.
|
||||||
|
- Specs: `LetterBucket.svelte.spec.ts`, `YearBand.svelte.spec.ts`, `messages.spec.ts` (extend), plus the route spec stays green.
|
||||||
|
- `.specify/rtm.md` — update REQ-014/REQ-020 rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Preview cap + show-more toggle (drop the sparkline)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/lib/timeline/timelineGrouping.ts`
|
||||||
|
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
|
||||||
|
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the i18n keys** in all three locales (so the toggle has a label).
|
||||||
|
|
||||||
|
`frontend/messages/de.json` (next to the existing `timeline_bucket_*` keys):
|
||||||
|
```json
|
||||||
|
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||||
|
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||||
|
```
|
||||||
|
`en.json`: `"+ {count} more letters"`, `"Show fewer"`. `es.json`: `"+ {count} cartas más"`, `"Mostrar menos"`.
|
||||||
|
Run `npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide` (from `frontend/`) or let the dev/test build compile them.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing tests** in `LetterBucket.svelte.spec.ts` (replace the `manyLetters`-based density tests from PR #847 — the sparkline is going away).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const manyLetters = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` }));
|
||||||
|
|
||||||
|
describe('LetterBucket — preview cap + show-more (#827 redesign)', () => {
|
||||||
|
it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
|
||||||
|
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Krieg', color: 'sienna', letters: manyLetters(8) };
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands to all letters and collapses back on toggle', async () => {
|
||||||
|
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Krieg', color: 'sienna', letters: manyLetters(8) };
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(8);
|
||||||
|
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all letters and no toggle for a small cluster (<= 5)', () => {
|
||||||
|
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Tod', color: null, letters: manyLetters(3) };
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
|
||||||
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Add `import { tick } from 'svelte';` at the top of the spec if absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the tests — verify they fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL (still rendering the strip / all cards).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement.** In `timelineGrouping.ts` remove `BUCKET_DENSE_THRESHOLD` + `isBucketDense`, add:
|
||||||
|
```ts
|
||||||
|
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
|
||||||
|
export const CLUSTER_PREVIEW = 5;
|
||||||
|
```
|
||||||
|
In `LetterBucket.svelte`: remove the `YearLetterStrip` import + the `dense`/strip branch. Add expand state and a visible-slice derived; render `CLUSTER_PREVIEW` compact cards, then a toggle when there are more:
|
||||||
|
```svelte
|
||||||
|
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
// ...
|
||||||
|
let expanded = $state(false);
|
||||||
|
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
|
||||||
|
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
|
||||||
|
```
|
||||||
|
Body markup (replace the `{#if dense}…{:else}…` block):
|
||||||
|
```svelte
|
||||||
|
<ul class="space-y-1.5">
|
||||||
|
{#each visible as letter (entryKey(letter))}
|
||||||
|
<li><LetterCard entry={letter} variant={cardVariant} suppressTagChip={mode === 'thema'} compact={true} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if hiddenCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="bucket-show-more"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||||
|
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
{expanded ? m.timeline_bucket_show_less() : m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests — verify they pass.** Same command → PASS. Also run `src/lib/timeline/timelineGrouping.spec.ts --project=server` (still green; only constants changed).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit.**
|
||||||
|
```bash
|
||||||
|
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/timelineGrouping.ts src/lib/timeline/LetterBucket.svelte.spec.ts messages/de.json messages/en.json messages/es.json
|
||||||
|
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/timelineGrouping.ts frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/messages/{de,en,es}.json
|
||||||
|
git diff --cached --stat
|
||||||
|
git commit -m "$(printf 'feat(timeline): cap grouped clusters at 5 letters with a show-more toggle\n\nReplaces the in-bucket month-density sparkline with a first-5 preview + show-more\n/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12\nYearLetterStrip.\n\nRefs #827')"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Collapsed drawer for the leftover bin
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
|
||||||
|
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
|
||||||
|
|
||||||
|
The fallback bucket (`kind === 'fallback'` — "Weitere Briefe"/"Ohne Thema") is a junk drawer: render it **collapsed** (count only, no letters) until the user reveals it; revealing shows the same first-5 + show-more body.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests.**
|
||||||
|
```ts
|
||||||
|
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
|
||||||
|
const fb = (n: number, mode: 'event' | 'thema'): Bucket => ({
|
||||||
|
key: '__fallback__', kind: 'fallback', color: null,
|
||||||
|
letters: Array.from({ length: n }, (_, i) => makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` }))
|
||||||
|
});
|
||||||
|
it('renders collapsed — count + reveal, no letter cards — until opened', () => {
|
||||||
|
render(LetterBucket, { bucket: fb(20, 'event'), mode: 'event', year: 1916 });
|
||||||
|
expect(document.querySelector('a.lcard')).toBeNull();
|
||||||
|
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
|
||||||
|
expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
it('reveals the first 5 letters when opened', async () => {
|
||||||
|
render(LetterBucket, { bucket: fb(20, 'event'), mode: 'event', year: 1916 });
|
||||||
|
(document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — verify fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement.** In `LetterBucket.svelte` add a `revealed` state defaulting to `bucket.kind !== 'fallback'` (non-drawers start open). Gate the body on it; when collapsed, render only the header + a reveal button:
|
||||||
|
```svelte
|
||||||
|
let revealed = $state(bucket.kind !== 'fallback');
|
||||||
|
// header always renders; body only when revealed
|
||||||
|
```
|
||||||
|
Collapsed drawer markup (when `!revealed`): the fallback label + count already render in the header; add the reveal control:
|
||||||
|
```svelte
|
||||||
|
{#if !revealed}
|
||||||
|
<button type="button" data-testid="bucket-reveal" onclick={() => (revealed = true)}
|
||||||
|
style="display:inline-flex;align-items:center;min-height:44px"
|
||||||
|
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy">
|
||||||
|
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<!-- the Task-1 body (ul + show-more) -->
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
Give the drawer a dashed neutral rail: add `class:border-dashed={bucket.kind === 'fallback'}` to the card.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — verify pass.** Same command → PASS. Re-run the Task-1 tests too (still green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||||
|
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||||
|
git diff --cached --stat
|
||||||
|
git commit -m "$(printf 'feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer\n\nThe catch-all bucket renders count-only by default behind a reveal control, then\nexpands to the first-5 + show-more body. Keeps the junk drawer quiet instead of\nflooding the timeline.\n\nRefs #827')"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Card chrome (the cluster is one contained card)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
|
||||||
|
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
|
||||||
|
|
||||||
|
Turn the `<section>` (rail-only, from PR #847) into a bordered card: `rounded` + `border border-line` + `bg-surface` + `shadow-sm`, keeping the coloured left rail (mint for event cluster, tag colour for tag, dashed neutral for the drawer). Header on a subtle `bg-canvas`/tint bar.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test.**
|
||||||
|
```ts
|
||||||
|
it('renders the cluster as a contained card (bordered, rounded, surface)', () => {
|
||||||
|
const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Krieg', color: 'sienna', letters: [makeEntry({ documentId: 'a' })] };
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||||
|
expect(card.className).toMatch(/\brounded\b|rounded-/);
|
||||||
|
expect(card.className).toContain('border');
|
||||||
|
expect(card.className).toContain('bg-surface');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — verify fail.** → FAIL (current section is `my-3 border-l-2 pl-3`, no `bg-surface`/`rounded`/full `border`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement.** Update the `<section data-testid="letter-bucket">` classes to e.g.:
|
||||||
|
```
|
||||||
|
class="my-3 overflow-hidden rounded-md border border-line border-l-2 bg-surface shadow-sm"
|
||||||
|
```
|
||||||
|
keep `class:border-l-brand-mint={isEventCluster}`, `class:border-dashed={bucket.kind==='fallback'}`, and the inline `style={railStyle}` for the tag colour. Move the body padding inside (e.g. wrap header + body in a `px-3 py-2`), and give the header a tint bar (`bg-canvas` for events, plain for the drawer). Verify the existing "coloured left rail" test (`expect(section.style).toContain('var(--c-tag-sienna)')`) still holds — keep `railStyle` on the section.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — verify pass.** Run the whole `LetterBucket.svelte.spec.ts` → all PASS (including the PR #847 rail test).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||||
|
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
|
||||||
|
git diff --cached --stat
|
||||||
|
git commit -m "$(printf 'feat(timeline): make a grouped cluster one contained card\n\nWraps each cluster in a bordered, rounded surface card (keeping the colour rail)\nso the header and its letters read as a single unit.\n\nRefs #827')"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Same-year curated event becomes the card header (kills the duplicate)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte` (add `event` + `canWrite` props + event-header rendering)
|
||||||
|
- Modify: `frontend/src/lib/timeline/YearBand.svelte` (render the card in place of the pill for a same-year curated event)
|
||||||
|
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`, `frontend/src/lib/timeline/YearBand.svelte.spec.ts`
|
||||||
|
|
||||||
|
When a curated event has letters in the same band, the event IS the card header — no separate pill. Reuse `getAccentConfig` (glyph/label) + `timelineDateLabel` + the `kuratiert/abgeleitet` provenance + the `✎` edit affordance (curated + eventId + canWrite), mirroring `EventPill.svelte`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests.**
|
||||||
|
|
||||||
|
`LetterBucket.svelte.spec.ts`:
|
||||||
|
```ts
|
||||||
|
import { getAccentConfig } from './eventCardConfig';
|
||||||
|
it('renders the curated event as the card header when given an `event` (no separate pill)', () => {
|
||||||
|
const event = makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'e1',
|
||||||
|
title: 'Ein gewaltiger Stadtbrand', eventDate: '1916-07-06', senderName: '', receiverName: '', documentId: undefined });
|
||||||
|
const bucket: Bucket = { key: 'event:e1', kind: 'event', title: 'Ein gewaltiger Stadtbrand', color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })] };
|
||||||
|
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true });
|
||||||
|
const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement;
|
||||||
|
expect(header.textContent).toContain('Ein gewaltiger Stadtbrand');
|
||||||
|
expect(header.textContent).toContain(m.timeline_provenance_curated());
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe('/zeitstrahl/events/e1/edit');
|
||||||
|
});
|
||||||
|
it('shows no edit affordance in the header when canWrite is false', () => {
|
||||||
|
const event = makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'e1', title: 'X', senderName: '', receiverName: '', documentId: undefined });
|
||||||
|
const bucket: Bucket = { key: 'event:e1', kind: 'event', title: 'X', color: null, letters: [makeEntry({ documentId: 'a' })] };
|
||||||
|
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false });
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`YearBand.svelte.spec.ts` (replace/extend the PR #847 "nests an event cluster under its pill" test — the pill is now the card header):
|
||||||
|
```ts
|
||||||
|
it('renders a same-year curated event as one card header, with no separate pill and no duplicate title', () => {
|
||||||
|
const pill = makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'e1',
|
||||||
|
title: 'Ein gewaltiger Stadtbrand', eventDate: '1916-07-06', senderName: '', receiverName: '', documentId: undefined });
|
||||||
|
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1916-07-06' });
|
||||||
|
render(YearBand, { year: makeYear(1916, [pill, letter]), groupingMode: 'event', eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]), canWrite: true });
|
||||||
|
const occurrences = (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
||||||
|
expect(occurrences).toBe(1); // once — in the card header
|
||||||
|
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('a.lcard.ev')).not.toBeNull(); // its letter, inside
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — verify fail.** Both specs → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `LetterBucket` header.** Add props `event?: TimelineEntryDTO` and `canWrite = false`. Derive (mirroring `EventPill.svelte`):
|
||||||
|
```svelte
|
||||||
|
import { getAccentConfig } from './eventCardConfig';
|
||||||
|
import { timelineDateLabel } from './dateLabel';
|
||||||
|
// ...
|
||||||
|
const accent = $derived(event ? getAccentConfig(event) : null);
|
||||||
|
const eventDateLabel = $derived(event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null);
|
||||||
|
const provenance = $derived(event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated());
|
||||||
|
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
|
||||||
|
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
|
||||||
|
```
|
||||||
|
Header branch order: `if (event)` → event header (`data-testid="bucket-event-header"`: glyph from `accent.glyph` aria-hidden + sr-only `accent.label`, `event.title`, `eventSubtitle`, count, and the `✎` link `/zeitstrahl/events/{event.eventId}/edit` with `data-testid="event-edit"` when `canEdit`); else the existing `thema/tag` chip / `fallback` label branches.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `YearBand` Ereignis branch.** When a curated event entry has a matching same-year bucket, render the card *instead of* the pill, passing `event={entry}` + `canWrite`; do **not** also push the `{ t: 'event' }` pill row for it. Letterless/derived/world events still push their pill/band row. Sketch:
|
||||||
|
```svelte
|
||||||
|
if (groupingMode === 'event') {
|
||||||
|
const buckets = bucketLetters(letters, 'event', eventLookup);
|
||||||
|
const sameYear = (id) => buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`);
|
||||||
|
for (const entry of year.entries) {
|
||||||
|
if (entry.kind !== 'EVENT') continue;
|
||||||
|
const bucket = entry.eventId ? sameYear(entry.eventId) : undefined;
|
||||||
|
if (bucket) out.push({ t: 'eventcard', entry, bucket }); // card replaces pill
|
||||||
|
else out.push({ t: 'event', entry }); // plain pill/band
|
||||||
|
}
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (bucket.kind === 'fallback' || !year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key))
|
||||||
|
out.push({ t: 'bucket', bucket, nested: false }); // cross-year cluster / drawer
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add a `Row` variant `{ t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }` and template branch:
|
||||||
|
```svelte
|
||||||
|
{:else if row.t === 'eventcard'}
|
||||||
|
<LetterBucket bucket={row.bucket} mode="event" year={year.year} event={row.entry} canWrite={canWrite} />
|
||||||
|
```
|
||||||
|
Keep the existing `{ t: 'bucket' }` branch (cross-year clusters + drawer) rendering `<LetterBucket … nested={false} />` with no `event` prop → text header. Remember: **no `new Map`/`Set` in the component** — use `buckets.find` / `year.entries.some` as above.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run — verify pass.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts --project=client` → PASS. The identity spec (REQ-001) still passes because derived/world fixtures are unchanged; if a now-stale assertion expects a pill for a *curated-with-letters* event, update it to expect the card header (REQ-001 amendment) and note it in the commit.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit.**
|
||||||
|
```bash
|
||||||
|
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/YearBand.svelte src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts
|
||||||
|
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/YearBand.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/src/lib/timeline/YearBand.svelte.spec.ts
|
||||||
|
git diff --cached --stat
|
||||||
|
git commit -m "$(printf 'feat(timeline): render a same-year curated event as its cluster card header\n\nA curated event with letters in its own band now becomes the contained card header\n(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —\nthe title reads once. Derived life-events, world-bands, and letterless event pills\nare unchanged (REQ-001 amended for curated-with-letters).\n\nRefs #827')"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Regression sweep + route view + docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: route + cross-year + thema specs
|
||||||
|
- Modify: `.specify/rtm.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the affected specs (client).**
|
||||||
|
```
|
||||||
|
npx vitest run \
|
||||||
|
src/lib/timeline/BucketHeaderChip.svelte.spec.ts src/lib/timeline/LetterCard.svelte.spec.ts \
|
||||||
|
src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts \
|
||||||
|
src/lib/timeline/GroupingControl.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts \
|
||||||
|
src/lib/timeline/TimelineView.svelte.spec.ts src/routes/zeitstrahl/page.svelte.spec.ts --project=client
|
||||||
|
```
|
||||||
|
Expected: all PASS. Fix any cross-year/thema spec that assumed the old header (it should now find the card; thema card header is still the `BucketHeaderChip`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the server specs.** `npx vitest run src/lib/timeline/timelineGrouping.spec.ts src/lib/timeline/timeline-no-raw-html.spec.ts src/lib/messages.spec.ts --project=server` → PASS. If `messages.spec.ts` parity fails, it's the two new keys — they must be in de/en/es.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Type-check the changed files.** `npm run check 2>&1 | grep -E "LetterBucket|YearBand|timelineGrouping"` → no ERROR lines (baseline noise elsewhere is fine).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the RTM.** In `.specify/rtm.md`, edit REQ-014 (event-clustered letters live inside a contained card whose header is the same-year event; first-5 + show-more) and REQ-020 (clusters are contained cards with a 5-letter preview + show-more; the leftover bin is a collapsed drawer; the sparkline is no longer used in grouped mode), citing the new tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
cd .. && git add .specify/rtm.md
|
||||||
|
git diff --cached --stat
|
||||||
|
git commit -m "$(printf 'docs(rtm): trace the grouped-view contained-card layout (#827)\n\nRefs #827')"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Push.** `git push origin feat/issue-827-zeitstrahl-grouping`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review notes (author)
|
||||||
|
|
||||||
|
- **Spec coverage:** contained card (Task 3) ✓; first-5 + show-more (Task 1) ✓; collapsed drawer (Task 2) ✓; same-year event → card header / no duplicate (Task 4) ✓; derived/world unchanged (Task 4 keeps plain rows) ✓; thema chip header reused (existing, verified Task 5) ✓; cross-year text header (existing `{t:'bucket'}` path, verified Task 5) ✓; sparkline dropped from grouped mode (Task 1) ✓.
|
||||||
|
- **Naming consistency:** `CLUSTER_PREVIEW` (Task 1) used in Tasks 1–2; testids `bucket-show-more` / `bucket-reveal` / `bucket-event-header` / `event-edit` consistent across tasks; `Row` variant `eventcard` defined and consumed in Task 4.
|
||||||
|
- **REQ-001 amendment** is intentional and documented in the spec; Task 4 Step 5 flags fixing any stale identity assertion.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Zeitstrahl grouped-view layout redesign
|
||||||
|
|
||||||
|
**Date:** 2026-06-15
|
||||||
|
**Feature:** #827 (regroup `/zeitstrahl` by Ereignis/Thema) — layout follow-up on PR #847
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
> The REQ contract for #827 lives in the Gitea issue body (and the amendment comment of
|
||||||
|
> 2026-06-15). This document records the **layout/visual design** agreed in the visual
|
||||||
|
> brainstorm and the REQ deltas it implies. Mockups: `.superpowers/brainstorm/*/content/`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The first grouped-view implementation (PR #847) fixed the flood and the duplicate event title,
|
||||||
|
but two issues remained on review of the live view:
|
||||||
|
|
||||||
|
1. **Weak belonging.** A clustered event's letters dropped below its centered pill as a
|
||||||
|
full-width block with only a thin left rail. The connection between an event and its letters
|
||||||
|
read weakly — the eye couldn't tell the letters belonged to the pill above.
|
||||||
|
2. **Layout inconsistency.** In Datum mode letters alternate left/right of the centered spine
|
||||||
|
(events/density centered). In grouped mode the letters became full-width, breaking that
|
||||||
|
rhythm with no clear reason.
|
||||||
|
|
||||||
|
## Decision: a cluster is one contained card
|
||||||
|
|
||||||
|
A clustered event (Ereignis) or root tag (Thema) renders as **one bordered card** whose header
|
||||||
|
is the event/tag itself and whose body holds that cluster's letters. Belonging becomes
|
||||||
|
structural (a single container), not positional guesswork. This replaces the full-width block.
|
||||||
|
|
||||||
|
### Ereignis mode, per year band
|
||||||
|
|
||||||
|
1. **Derived life-events** (Geburt/Tod/Heirat, `abgeleitet`) never cluster — they carry no
|
||||||
|
document links, so they are always **plain axis fixtures, unchanged from Datum mode**. A
|
||||||
|
**world-band** (`historisch`) is normally letterless and stays a plain band; on the rare
|
||||||
|
occasion a historical event has linked letters it follows rule 2 (becomes a card).
|
||||||
|
2. **A curated event (PERSONAL or HISTORICAL) with letters in this band** → one mint-bordered card:
|
||||||
|
- **Header** = the event's glyph + title + date + `kuratiert` + edit-✎ + count (the pill's
|
||||||
|
content, laid out as a header bar). This *replaces* the separate floating pill for that
|
||||||
|
event in this band — killing the duplicate title.
|
||||||
|
- **Body** = the cluster's letters, **first 5 shown, then a "+ N weitere Briefe anzeigen"
|
||||||
|
toggle** that expands/collapses the rest. Letters use the compact `LetterCard` variant.
|
||||||
|
3. **A curated event with no letters in this band** → stays a plain centered pill (no empty card).
|
||||||
|
4. **A curated event whose letters fall in a different year than its pill** → those letters form a
|
||||||
|
labeled card in *their* year (header = event name as text, no ✎/pill since the pill lives
|
||||||
|
elsewhere); the pill stays in its own band. No adjacent duplication.
|
||||||
|
5. **Leftover letters** (linked to no surviving curated event) → a collapsed neutral, dashed
|
||||||
|
**"✉ N Briefe ohne Ereignis · anzeigen ›"** drawer. Clicking expands to the same first-5 +
|
||||||
|
show-more list. No preview letters until opened.
|
||||||
|
|
||||||
|
### Thema mode
|
||||||
|
|
||||||
|
Identical shape. Each card's header is the **tinted root-tag chip** (`● Krieg · 24`,
|
||||||
|
`BucketHeaderChip`, fixed-ink label per the contrast fix) instead of an event pill; there is no
|
||||||
|
axis pill for a tag, so every tag cluster is a standalone card. The per-letter `TagChip` stays
|
||||||
|
suppressed inside its own card (REQ-017). The leftover drawer reads **"Ohne Thema"**.
|
||||||
|
|
||||||
|
### Layout / spine
|
||||||
|
|
||||||
|
- Cluster cards are **centered on the spine** (like events already are), not full-width-flush —
|
||||||
|
consistent with how grouped units (events) relate to the axis. Individual chronological
|
||||||
|
letters keep alternating left/right only in **Datum** mode.
|
||||||
|
- Each card carries a colour left rail: **mint** for an Ereignis cluster, the **tag colour** for
|
||||||
|
a Thema cluster, **neutral dashed** for the leftover drawer.
|
||||||
|
|
||||||
|
## Components affected
|
||||||
|
|
||||||
|
- `LetterBucket.svelte` — becomes the contained card: header slot (pill-content / tag chip /
|
||||||
|
drawer label / cross-year text label) + body with the first-5 cap and the show-more toggle.
|
||||||
|
Drop the `YearLetterStrip` (sparkline) branch from grouped mode.
|
||||||
|
- `YearBand.svelte` — in Ereignis mode, a same-year curated event renders *as* the card header
|
||||||
|
(merge pill into the card) instead of pill-then-nested-bucket; derived/world/letterless events
|
||||||
|
stay plain; cross-year clusters and the leftover drawer render after the axis entries.
|
||||||
|
- `LetterCard.svelte` — compact variant already exists (PR #847); reused inside cards.
|
||||||
|
- `BucketHeaderChip.svelte` — reused as the Thema card header (contrast fix already shipped).
|
||||||
|
- `timelineGrouping.ts` — the first-visible cap (`CLUSTER_PREVIEW = 5`) replaces
|
||||||
|
`BUCKET_DENSE_THRESHOLD`; helpers unchanged otherwise.
|
||||||
|
- Possibly a small `ClusterCard`/header sub-component if `LetterBucket` grows too large.
|
||||||
|
|
||||||
|
## REQ deltas (to fold into issue #827)
|
||||||
|
|
||||||
|
- **REQ-001 (amended):** derived life-events, world-bands, and *letterless* curated event pills
|
||||||
|
render identically across modes; a curated event **that has letters** renders as its cluster
|
||||||
|
card's header in grouped mode (no longer byte-identical to its Datum pill). Every event keeps
|
||||||
|
its spine position (year).
|
||||||
|
- **REQ-003 / REQ-014 (amended):** event-clustered letters live inside a contained card; the
|
||||||
|
header is the event (same-year) or a text label (cross-year). First 5 shown + show-more.
|
||||||
|
- **REQ-020 (amended):** grouped clusters are contained colour-railed cards with a first-5
|
||||||
|
preview + show-more toggle; the leftover bin is a collapsed count-only drawer. The
|
||||||
|
month-density `YearLetterStrip` is **no longer used in grouped mode** (still used in Datum
|
||||||
|
dense years).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Datum mode (untouched — keeps the alternating-axis zigzag and the >12 sparkline strip).
|
||||||
|
- Backend / DTO (`linkedEventId` and root-tag fields already shipped; no change).
|
||||||
|
- New i18n beyond a show-more / drawer label string set.
|
||||||
|
|
||||||
|
## Testing approach
|
||||||
|
|
||||||
|
TDD per component, mirroring PR #847: `LetterBucket` (card header variants, first-5 cap,
|
||||||
|
show-more expand/collapse, drawer collapsed-by-default, colour rail), `YearBand` (same-year merge
|
||||||
|
= no duplicate title; cross-year keeps a label; derived/world pills unchanged), and the route
|
||||||
|
spec for the assembled view. Run targeted `--project=client` / `--project=server` specs only.
|
||||||
@@ -50,7 +50,7 @@ src/
|
|||||||
│ │ ├── relationship/ # Relationship form + chip components
|
│ │ ├── relationship/ # Relationship form + chip components
|
||||||
│ │ └── genealogy/ # Stammbaum (family tree) components
|
│ │ └── genealogy/ # Stammbaum (family tree) components
|
||||||
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
|
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
|
||||||
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
|
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, LetterBucket, BucketHeaderChip, GroupingControl, TagChip, YearLetterStrip, GapSpan; dateLabel + timelineDensity + timelineFilter + timelineGrouping + eventCardConfig (imports $lib/shared only, never document/)
|
||||||
│ ├── geschichte/ # Geschichte (story) domain: editor + card
|
│ ├── geschichte/ # Geschichte (story) domain: editor + card
|
||||||
│ ├── notification/ # Notification bell + dropdown + store
|
│ ├── notification/ # Notification bell + dropdown + store
|
||||||
│ ├── activity/ # Activity feed (Chronik) components
|
│ ├── activity/ # Activity feed (Chronik) components
|
||||||
|
|||||||
93
frontend/e2e/zeitstrahl-filter.spec.ts
Normal file
93
frontend/e2e/zeitstrahl-filter.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global /zeitstrahl layer filter (#780). Runs against the real stack with the
|
||||||
|
* seeded admin session (auth.setup). Covers the primary journey (hide the
|
||||||
|
* Letters layer → letter cards vanish + the trigger reports one active filter →
|
||||||
|
* reset restores everything) and a 375px axe pass with the collapsible open in
|
||||||
|
* both light and dark mode.
|
||||||
|
*
|
||||||
|
* #779 (the /zeitstrahl route) is merged, so this spec is NOT skipped. Per
|
||||||
|
* e2e/CLAUDE.md, E2E is not yet wired into CI — this axe gate runs locally only
|
||||||
|
* for now.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
|
||||||
|
const res = await request.post('/api/persons', {
|
||||||
|
data: { personType: 'PERSON', firstName, lastName }
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
|
||||||
|
return (await res.json()).id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds one dated letter so the timeline has content (and a LetterCard to hide). */
|
||||||
|
async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) {
|
||||||
|
const senderId = await createPerson(request, 'Filter-Test', `Absender ${stamp()}`);
|
||||||
|
const receiverId = await createPerson(request, 'Filter-Test', `Empfaenger ${stamp()}`);
|
||||||
|
|
||||||
|
const createRes = await request.post('/api/documents', { multipart: { title } });
|
||||||
|
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
|
||||||
|
const docId = (await createRes.json()).id as string;
|
||||||
|
|
||||||
|
const put = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title,
|
||||||
|
documentDate: isoDate,
|
||||||
|
metaDatePrecision: 'DAY',
|
||||||
|
senderId,
|
||||||
|
receiverIds: receiverId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Zeitstrahl — layer filter (#780)', () => {
|
||||||
|
test('hiding the Letters layer removes letter cards and reports the active count; reset restores', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
// A sparse year keeps the seeded letter an individual card (not a dense strip).
|
||||||
|
const title = `E2E Filter Brief ${stamp()}`;
|
||||||
|
await seedDatedLetter(request, '1903-03-03', title);
|
||||||
|
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.getByText(title)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
|
||||||
|
await expect(page.getByText(title)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('timeline-filter-trigger')).toContainText('1 aktiv');
|
||||||
|
|
||||||
|
await page.getByTestId('timeline-filter-reset').click();
|
||||||
|
await expect(page.getByText(title)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no wcag2a/wcag2aa violations at 375px with the filter bar open (light + dark)', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
await seedDatedLetter(request, '1915-06-15', `E2E Filter A11y ${stamp()}`);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 375, height: 800 });
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the collapsible so axe scans the toggles, not just the trigger.
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await expect(page.getByTestId('timeline-filter-personal')).toBeVisible();
|
||||||
|
|
||||||
|
const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||||
|
|
||||||
|
const light = await scan();
|
||||||
|
expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]);
|
||||||
|
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||||
|
const dark = await scan();
|
||||||
|
expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
frontend/e2e/zeitstrahl-grouping.spec.ts
Normal file
123
frontend/e2e/zeitstrahl-grouping.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global /zeitstrahl grouping toggle (#827). Runs against the real stack with the seeded admin
|
||||||
|
* session (auth.setup). Covers REQ-002 (switching modes issues zero extra GET /api/timeline
|
||||||
|
* requests — the regroup is client-side), REQ-011 (the control stays usable and overflow-free at
|
||||||
|
* 320px with full-word aria-labels and ≥44px tap targets), and REQ-010g (a 320px axe pass over
|
||||||
|
* the control in both light and dark mode).
|
||||||
|
*
|
||||||
|
* Per e2e/CLAUDE.md, E2E is not yet wired into CI — this gate runs locally for now, like the
|
||||||
|
* #780 layer-filter spec it mirrors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
|
||||||
|
const res = await request.post('/api/persons', {
|
||||||
|
data: { personType: 'PERSON', firstName, lastName }
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
|
||||||
|
return (await res.json()).id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds one dated letter so the timeline has a loose letter and the grouping control is enabled. */
|
||||||
|
async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) {
|
||||||
|
const senderId = await createPerson(request, 'Group-Test', `Absender ${stamp()}`);
|
||||||
|
const receiverId = await createPerson(request, 'Group-Test', `Empfaenger ${stamp()}`);
|
||||||
|
|
||||||
|
const createRes = await request.post('/api/documents', { multipart: { title } });
|
||||||
|
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
|
||||||
|
const docId = (await createRes.json()).id as string;
|
||||||
|
|
||||||
|
const put = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title,
|
||||||
|
documentDate: isoDate,
|
||||||
|
metaDatePrecision: 'DAY',
|
||||||
|
senderId,
|
||||||
|
receiverIds: receiverId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Zeitstrahl — grouping toggle (#827)', () => {
|
||||||
|
test('switching grouping modes issues no extra timeline fetch (REQ-002)', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
await seedDatedLetter(request, '1909-05-05', `E2E Group Brief ${stamp()}`);
|
||||||
|
|
||||||
|
let timelineRequests = 0;
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.url().includes('/api/timeline')) timelineRequests++;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.getByTestId('grouping-control')).toBeVisible();
|
||||||
|
|
||||||
|
const afterLoad = timelineRequests;
|
||||||
|
await page.locator('[data-value="event"]').click();
|
||||||
|
await page.locator('[data-value="thema"]').click();
|
||||||
|
await page.locator('[data-value="date"]').click();
|
||||||
|
|
||||||
|
// the regroup is a pure client-side transform — not one more GET /api/timeline
|
||||||
|
expect(timelineRequests).toBe(afterLoad);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the control stays overflow-free and operable at 320px (REQ-011)', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
await seedDatedLetter(request, '1911-02-02', `E2E Group 320 ${stamp()}`);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
const control = page.getByTestId('grouping-control');
|
||||||
|
await expect(control).toBeVisible();
|
||||||
|
|
||||||
|
// the control fits inside the 320px viewport — no horizontal overflow
|
||||||
|
const box = await control.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.x + box!.width).toBeLessThanOrEqual(321);
|
||||||
|
|
||||||
|
for (const [value, fullWord] of [
|
||||||
|
['date', 'Datum'],
|
||||||
|
['event', 'Ereignis'],
|
||||||
|
['thema', 'Thema']
|
||||||
|
]) {
|
||||||
|
const radio = page.locator(`[data-value="${value}"]`);
|
||||||
|
const radioBox = await radio.boundingBox();
|
||||||
|
expect(radioBox!.height).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(radioBox!.width).toBeGreaterThanOrEqual(44);
|
||||||
|
// the abbreviated segment still announces its full word
|
||||||
|
expect(await radio.getAttribute('aria-label')).toBe(fullWord);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no wcag2a/wcag2aa violations on the grouping control at 320px (light + dark) (REQ-010g)', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
await seedDatedLetter(request, '1915-06-15', `E2E Group A11y ${stamp()}`);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.getByTestId('grouping-control')).toBeVisible();
|
||||||
|
|
||||||
|
const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||||
|
|
||||||
|
const light = await scan();
|
||||||
|
expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]);
|
||||||
|
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||||
|
const dark = await scan();
|
||||||
|
expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,6 +188,7 @@
|
|||||||
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
|
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
|
||||||
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||||
|
"person_add_event": "Ereignis für diese Person",
|
||||||
"person_docs_heading": "Gesendete Dokumente",
|
"person_docs_heading": "Gesendete Dokumente",
|
||||||
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
||||||
"person_received_docs_heading": "Empfangene Dokumente",
|
"person_received_docs_heading": "Empfangene Dokumente",
|
||||||
@@ -651,6 +652,7 @@
|
|||||||
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
||||||
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
|
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
|
||||||
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
|
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
|
||||||
|
"error_invalid_relationship_dates": "Das Ende-Datum darf nicht vor dem Beginn-Datum liegen.",
|
||||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
@@ -1034,6 +1036,7 @@
|
|||||||
"nav_geschichten": "Geschichten",
|
"nav_geschichten": "Geschichten",
|
||||||
"nav_zeitstrahl": "Zeitstrahl",
|
"nav_zeitstrahl": "Zeitstrahl",
|
||||||
"timeline_heading": "Zeitstrahl",
|
"timeline_heading": "Zeitstrahl",
|
||||||
|
"timeline_add_event": "Ereignis hinzufügen",
|
||||||
"timeline_empty_state": "Noch keine Ereignisse.",
|
"timeline_empty_state": "Noch keine Ereignisse.",
|
||||||
"timeline_undated_section": "Ohne Datum",
|
"timeline_undated_section": "Ohne Datum",
|
||||||
"timeline_unknown_person": "Unbekannt",
|
"timeline_unknown_person": "Unbekannt",
|
||||||
@@ -1047,6 +1050,21 @@
|
|||||||
"timeline_derived_death": "Tod",
|
"timeline_derived_death": "Tod",
|
||||||
"timeline_derived_marriage": "Heirat",
|
"timeline_derived_marriage": "Heirat",
|
||||||
"timeline_grouping_date": "Gruppierung: Datum",
|
"timeline_grouping_date": "Gruppierung: Datum",
|
||||||
|
"timeline_grouping_event": "Gruppierung: Ereignis",
|
||||||
|
"timeline_grouping_thema": "Gruppierung: Thema",
|
||||||
|
"timeline_grouping_aria_label": "Gruppierung",
|
||||||
|
"timeline_grouping_segment_date": "Datum",
|
||||||
|
"timeline_grouping_segment_event": "Ereignis",
|
||||||
|
"timeline_grouping_segment_thema": "Thema",
|
||||||
|
"timeline_grouping_segment_date_short": "Dat.",
|
||||||
|
"timeline_grouping_segment_event_short": "Ereig.",
|
||||||
|
"timeline_grouping_segment_thema_short": "Thema",
|
||||||
|
"timeline_grouping_disabled_reason": "Briefe sind ausgeblendet – es gibt nichts zu gruppieren.",
|
||||||
|
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
|
||||||
|
"timeline_bucket_other_letters": "Weitere Briefe",
|
||||||
|
"timeline_bucket_no_topic": "Ohne Thema",
|
||||||
|
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||||
|
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||||
"timeline_provenance_derived": "abgeleitet",
|
"timeline_provenance_derived": "abgeleitet",
|
||||||
"timeline_provenance_curated": "kuratiert",
|
"timeline_provenance_curated": "kuratiert",
|
||||||
"timeline_letter_glyph_label": "Brief",
|
"timeline_letter_glyph_label": "Brief",
|
||||||
@@ -1056,6 +1074,14 @@
|
|||||||
"timeline_events_count": "{count} Ereignisse",
|
"timeline_events_count": "{count} Ereignisse",
|
||||||
"timeline_letters_count_singular": "1 Brief",
|
"timeline_letters_count_singular": "1 Brief",
|
||||||
"timeline_events_count_singular": "1 Ereignis",
|
"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_new_title": "Neues Ereignis",
|
||||||
"event_editor_edit_title": "Ereignis bearbeiten",
|
"event_editor_edit_title": "Ereignis bearbeiten",
|
||||||
"event_editor_section_when": "Wann",
|
"event_editor_section_when": "Wann",
|
||||||
@@ -1221,6 +1247,16 @@
|
|||||||
"relation_form_field_from_year": "Von Jahr",
|
"relation_form_field_from_year": "Von Jahr",
|
||||||
"relation_form_field_to_year": "Bis Jahr",
|
"relation_form_field_to_year": "Bis Jahr",
|
||||||
"relation_form_year_placeholder": "z.B. 1920",
|
"relation_form_year_placeholder": "z.B. 1920",
|
||||||
|
"relation_label_from_date": "Beginn (Datum)",
|
||||||
|
"relation_label_to_date": "Ende (Datum)",
|
||||||
|
"relation_label_date_precision": "Genauigkeit",
|
||||||
|
"relation_precision_day": "Genaues Datum (Tag)",
|
||||||
|
"relation_precision_month": "Monat bekannt",
|
||||||
|
"relation_precision_year": "Nur Jahreszahl",
|
||||||
|
"relation_label_notes": "Notizen",
|
||||||
|
"relation_notes_placeholder": "Optionaler Hinweis zu dieser Beziehung",
|
||||||
|
"relation_date_placeholder_hint": "Leer lassen, wenn unbekannt",
|
||||||
|
"relation_edit": "Beziehung bearbeiten",
|
||||||
"person_relationships_heading": "Beziehungen",
|
"person_relationships_heading": "Beziehungen",
|
||||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
|
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
|
||||||
"person_year_error": "Please enter a four-digit year",
|
"person_year_error": "Please enter a four-digit year",
|
||||||
"person_years_error_order": "Birth year must be before death year",
|
"person_years_error_order": "Birth year must be before death year",
|
||||||
|
"person_add_event": "Add event for this person",
|
||||||
"person_docs_heading": "Sent documents",
|
"person_docs_heading": "Sent documents",
|
||||||
"person_no_docs": "This person has not yet been linked as a sender.",
|
"person_no_docs": "This person has not yet been linked as a sender.",
|
||||||
"person_received_docs_heading": "Received documents",
|
"person_received_docs_heading": "Received documents",
|
||||||
@@ -651,6 +652,7 @@
|
|||||||
"error_invalid_date_range": "The end date must not be before the start date.",
|
"error_invalid_date_range": "The end date must not be before the start date.",
|
||||||
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
|
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
|
||||||
"error_invalid_date_precision": "Date and precision do not match.",
|
"error_invalid_date_precision": "Date and precision do not match.",
|
||||||
|
"error_invalid_relationship_dates": "The end date must not be before the start date.",
|
||||||
"validation_last_name_required": "Last name is required.",
|
"validation_last_name_required": "Last name is required.",
|
||||||
"validation_first_name_required": "First name is required.",
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
@@ -1034,6 +1036,7 @@
|
|||||||
"nav_geschichten": "Stories",
|
"nav_geschichten": "Stories",
|
||||||
"nav_zeitstrahl": "Timeline",
|
"nav_zeitstrahl": "Timeline",
|
||||||
"timeline_heading": "Timeline",
|
"timeline_heading": "Timeline",
|
||||||
|
"timeline_add_event": "Add event",
|
||||||
"timeline_empty_state": "No events yet.",
|
"timeline_empty_state": "No events yet.",
|
||||||
"timeline_undated_section": "Without Date",
|
"timeline_undated_section": "Without Date",
|
||||||
"timeline_unknown_person": "Unknown",
|
"timeline_unknown_person": "Unknown",
|
||||||
@@ -1047,6 +1050,21 @@
|
|||||||
"timeline_derived_death": "Death",
|
"timeline_derived_death": "Death",
|
||||||
"timeline_derived_marriage": "Marriage",
|
"timeline_derived_marriage": "Marriage",
|
||||||
"timeline_grouping_date": "Grouping: Date",
|
"timeline_grouping_date": "Grouping: Date",
|
||||||
|
"timeline_grouping_event": "Grouping: Event",
|
||||||
|
"timeline_grouping_thema": "Grouping: Topic",
|
||||||
|
"timeline_grouping_aria_label": "Grouping",
|
||||||
|
"timeline_grouping_segment_date": "Date",
|
||||||
|
"timeline_grouping_segment_event": "Event",
|
||||||
|
"timeline_grouping_segment_thema": "Topic",
|
||||||
|
"timeline_grouping_segment_date_short": "Date",
|
||||||
|
"timeline_grouping_segment_event_short": "Event",
|
||||||
|
"timeline_grouping_segment_thema_short": "Topic",
|
||||||
|
"timeline_grouping_disabled_reason": "Letters are hidden — there is nothing to group.",
|
||||||
|
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
|
||||||
|
"timeline_bucket_other_letters": "More letters",
|
||||||
|
"timeline_bucket_no_topic": "No topic",
|
||||||
|
"timeline_bucket_show_more": "+ {count} more letters",
|
||||||
|
"timeline_bucket_show_less": "Show fewer",
|
||||||
"timeline_provenance_derived": "derived",
|
"timeline_provenance_derived": "derived",
|
||||||
"timeline_provenance_curated": "curated",
|
"timeline_provenance_curated": "curated",
|
||||||
"timeline_letter_glyph_label": "Letter",
|
"timeline_letter_glyph_label": "Letter",
|
||||||
@@ -1056,6 +1074,14 @@
|
|||||||
"timeline_events_count": "{count} events",
|
"timeline_events_count": "{count} events",
|
||||||
"timeline_letters_count_singular": "1 letter",
|
"timeline_letters_count_singular": "1 letter",
|
||||||
"timeline_events_count_singular": "1 event",
|
"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_new_title": "New event",
|
||||||
"event_editor_edit_title": "Edit event",
|
"event_editor_edit_title": "Edit event",
|
||||||
"event_editor_section_when": "When",
|
"event_editor_section_when": "When",
|
||||||
@@ -1221,6 +1247,16 @@
|
|||||||
"relation_form_field_from_year": "From year",
|
"relation_form_field_from_year": "From year",
|
||||||
"relation_form_field_to_year": "To year",
|
"relation_form_field_to_year": "To year",
|
||||||
"relation_form_year_placeholder": "e.g. 1920",
|
"relation_form_year_placeholder": "e.g. 1920",
|
||||||
|
"relation_label_from_date": "Start date",
|
||||||
|
"relation_label_to_date": "End date",
|
||||||
|
"relation_label_date_precision": "Precision",
|
||||||
|
"relation_precision_day": "Exact date (day)",
|
||||||
|
"relation_precision_month": "Month known",
|
||||||
|
"relation_precision_year": "Year only",
|
||||||
|
"relation_label_notes": "Notes",
|
||||||
|
"relation_notes_placeholder": "Optional note about this relationship",
|
||||||
|
"relation_date_placeholder_hint": "Leave empty if unknown",
|
||||||
|
"relation_edit": "Edit relationship",
|
||||||
"person_relationships_heading": "Relationships",
|
"person_relationships_heading": "Relationships",
|
||||||
"person_relationships_empty": "No relationships known yet.",
|
"person_relationships_empty": "No relationships known yet.",
|
||||||
"timeline_aria_label": "Document density timeline",
|
"timeline_aria_label": "Document density timeline",
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
|
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
|
||||||
"person_year_error": "Introduzca un año de cuatro dígitos",
|
"person_year_error": "Introduzca un año de cuatro dígitos",
|
||||||
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||||
|
"person_add_event": "Añadir evento para esta persona",
|
||||||
"person_docs_heading": "Documentos enviados",
|
"person_docs_heading": "Documentos enviados",
|
||||||
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
||||||
"person_received_docs_heading": "Documentos recibidos",
|
"person_received_docs_heading": "Documentos recibidos",
|
||||||
@@ -651,6 +652,7 @@
|
|||||||
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
||||||
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
|
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
|
||||||
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
|
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
|
||||||
|
"error_invalid_relationship_dates": "La fecha de fin no puede ser anterior a la de inicio.",
|
||||||
"validation_last_name_required": "El apellido es obligatorio.",
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
"validation_first_name_required": "El nombre es obligatorio.",
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
@@ -1034,6 +1036,7 @@
|
|||||||
"nav_geschichten": "Historias",
|
"nav_geschichten": "Historias",
|
||||||
"nav_zeitstrahl": "Línea de tiempo",
|
"nav_zeitstrahl": "Línea de tiempo",
|
||||||
"timeline_heading": "Línea de tiempo",
|
"timeline_heading": "Línea de tiempo",
|
||||||
|
"timeline_add_event": "Añadir evento",
|
||||||
"timeline_empty_state": "Aún no hay eventos.",
|
"timeline_empty_state": "Aún no hay eventos.",
|
||||||
"timeline_undated_section": "Sin Fecha",
|
"timeline_undated_section": "Sin Fecha",
|
||||||
"timeline_unknown_person": "Desconocido",
|
"timeline_unknown_person": "Desconocido",
|
||||||
@@ -1047,6 +1050,21 @@
|
|||||||
"timeline_derived_death": "Fallecimiento",
|
"timeline_derived_death": "Fallecimiento",
|
||||||
"timeline_derived_marriage": "Matrimonio",
|
"timeline_derived_marriage": "Matrimonio",
|
||||||
"timeline_grouping_date": "Agrupación: Fecha",
|
"timeline_grouping_date": "Agrupación: Fecha",
|
||||||
|
"timeline_grouping_event": "Agrupación: Evento",
|
||||||
|
"timeline_grouping_thema": "Agrupación: Tema",
|
||||||
|
"timeline_grouping_aria_label": "Agrupación",
|
||||||
|
"timeline_grouping_segment_date": "Fecha",
|
||||||
|
"timeline_grouping_segment_event": "Evento",
|
||||||
|
"timeline_grouping_segment_thema": "Tema",
|
||||||
|
"timeline_grouping_segment_date_short": "Fecha",
|
||||||
|
"timeline_grouping_segment_event_short": "Evento",
|
||||||
|
"timeline_grouping_segment_thema_short": "Tema",
|
||||||
|
"timeline_grouping_disabled_reason": "Las cartas están ocultas: no hay nada que agrupar.",
|
||||||
|
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
|
||||||
|
"timeline_bucket_other_letters": "Más cartas",
|
||||||
|
"timeline_bucket_no_topic": "Sin tema",
|
||||||
|
"timeline_bucket_show_more": "+ {count} cartas más",
|
||||||
|
"timeline_bucket_show_less": "Mostrar menos",
|
||||||
"timeline_provenance_derived": "derivado",
|
"timeline_provenance_derived": "derivado",
|
||||||
"timeline_provenance_curated": "curado",
|
"timeline_provenance_curated": "curado",
|
||||||
"timeline_letter_glyph_label": "Carta",
|
"timeline_letter_glyph_label": "Carta",
|
||||||
@@ -1056,6 +1074,14 @@
|
|||||||
"timeline_events_count": "{count} eventos",
|
"timeline_events_count": "{count} eventos",
|
||||||
"timeline_letters_count_singular": "1 carta",
|
"timeline_letters_count_singular": "1 carta",
|
||||||
"timeline_events_count_singular": "1 evento",
|
"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_new_title": "Nuevo evento",
|
||||||
"event_editor_edit_title": "Editar evento",
|
"event_editor_edit_title": "Editar evento",
|
||||||
"event_editor_section_when": "Cuándo",
|
"event_editor_section_when": "Cuándo",
|
||||||
@@ -1221,6 +1247,16 @@
|
|||||||
"relation_form_field_from_year": "Desde año",
|
"relation_form_field_from_year": "Desde año",
|
||||||
"relation_form_field_to_year": "Hasta año",
|
"relation_form_field_to_year": "Hasta año",
|
||||||
"relation_form_year_placeholder": "ej. 1920",
|
"relation_form_year_placeholder": "ej. 1920",
|
||||||
|
"relation_label_from_date": "Fecha de inicio",
|
||||||
|
"relation_label_to_date": "Fecha de fin",
|
||||||
|
"relation_label_date_precision": "Precisión",
|
||||||
|
"relation_precision_day": "Fecha exacta (día)",
|
||||||
|
"relation_precision_month": "Mes conocido",
|
||||||
|
"relation_precision_year": "Solo año",
|
||||||
|
"relation_label_notes": "Notas",
|
||||||
|
"relation_notes_placeholder": "Nota opcional sobre esta relación",
|
||||||
|
"relation_date_placeholder_hint": "Dejar vacío si es desconocido",
|
||||||
|
"relation_edit": "Editar relación",
|
||||||
"person_relationships_heading": "Relaciones",
|
"person_relationships_heading": "Relaciones",
|
||||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{id}/relationships/{relId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateRelationship"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteRelationship"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/geschichten/{id}/items/reorder": {
|
"/api/geschichten/{id}/items/reorder": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1640,22 +1656,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/persons/{id}/relationships/{relId}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete: operations["deleteRelationship"];
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/persons/{id}/aliases/{aliasId}": {
|
"/api/persons/{id}/aliases/{aliasId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1853,6 +1853,50 @@ export interface components {
|
|||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
};
|
};
|
||||||
|
RelationshipUpsertRequest: {
|
||||||
|
/** Format: uuid */
|
||||||
|
relatedPersonId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||||
|
/** Format: date */
|
||||||
|
fromDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
fromDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
/** Format: date */
|
||||||
|
toDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
toDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
RelationshipDTO: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
personId: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
relatedPersonId: string;
|
||||||
|
personDisplayName: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
personBirthYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
personDeathYear?: number;
|
||||||
|
relatedPersonDisplayName: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
relatedPersonBirthYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
relatedPersonDeathYear?: number;
|
||||||
|
/** @enum {string} */
|
||||||
|
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||||
|
/** Format: date */
|
||||||
|
fromDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
fromDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
/** Format: date */
|
||||||
|
toDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
toDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
JourneyReorderDTO: {
|
JourneyReorderDTO: {
|
||||||
itemIds?: string[];
|
itemIds?: string[];
|
||||||
};
|
};
|
||||||
@@ -2008,42 +2052,6 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
targetId: string;
|
targetId: string;
|
||||||
};
|
};
|
||||||
CreateRelationshipRequest: {
|
|
||||||
/** Format: uuid */
|
|
||||||
relatedPersonId: string;
|
|
||||||
/** @enum {string} */
|
|
||||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
|
||||||
/** Format: int32 */
|
|
||||||
fromYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
toYear?: number;
|
|
||||||
notes?: string;
|
|
||||||
};
|
|
||||||
RelationshipDTO: {
|
|
||||||
/** Format: uuid */
|
|
||||||
id: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
personId: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
relatedPersonId: string;
|
|
||||||
personDisplayName: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
personBirthYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
personDeathYear?: number;
|
|
||||||
relatedPersonDisplayName: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
relatedPersonBirthYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
relatedPersonDeathYear?: number;
|
|
||||||
/** @enum {string} */
|
|
||||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
|
||||||
/** Format: int32 */
|
|
||||||
fromYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
toYear?: number;
|
|
||||||
notes?: string;
|
|
||||||
};
|
|
||||||
PersonNameAliasDTO: {
|
PersonNameAliasDTO: {
|
||||||
lastName: string;
|
lastName: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -2459,6 +2467,8 @@ export interface components {
|
|||||||
rootTagId?: string;
|
rootTagId?: string;
|
||||||
rootTagName?: string;
|
rootTagName?: string;
|
||||||
rootTagColor?: string;
|
rootTagColor?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
linkedEventId?: string;
|
||||||
};
|
};
|
||||||
TimelineYearDTO: {
|
TimelineYearDTO: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -3200,6 +3210,54 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateRelationship: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
relId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipUpsertRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["RelationshipDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteRelationship: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
relId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
reorderItems: {
|
reorderItems: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3663,7 +3721,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["CreateRelationshipRequest"];
|
"application/json": components["schemas"]["RelationshipUpsertRequest"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
@@ -5909,27 +5967,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
deleteRelationship: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
id: string;
|
|
||||||
relId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description No Content */
|
|
||||||
204: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
removeAlias: {
|
removeAlias: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -98,4 +98,68 @@ describe('message key parity', () => {
|
|||||||
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
|
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
|
||||||
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
|
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #780 REQ-010: the layer-filter strings are Paraglide keys in every locale.
|
||||||
|
// timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count},
|
||||||
|
// ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)".
|
||||||
|
it('zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)', () => {
|
||||||
|
const requiredKeys = [
|
||||||
|
'timeline_filter_label_layers',
|
||||||
|
'timeline_filter_layer_personal',
|
||||||
|
'timeline_filter_layer_historical',
|
||||||
|
'timeline_filter_layer_letters',
|
||||||
|
'timeline_filter_trigger',
|
||||||
|
'timeline_filter_trigger_active',
|
||||||
|
'timeline_filter_reset',
|
||||||
|
'timeline_filter_empty_state'
|
||||||
|
];
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
|
||||||
|
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
|
||||||
|
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
// the active-count key carries the established {count} placeholder
|
||||||
|
expect(de.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
expect(en.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
expect(es.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #842: the two curator-affordance CTA labels (Zeitstrahl header + person page)
|
||||||
|
// are Paraglide keys present in every locale; the edit pencils reuse btn_edit.
|
||||||
|
it('curator-affordance CTA keys are present in all locales (#842)', () => {
|
||||||
|
for (const key of ['timeline_add_event', 'person_add_event']) {
|
||||||
|
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
|
||||||
|
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
|
||||||
|
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// #827 REQ-012: the grouping toggle + bucket strings are new Paraglide keys in
|
||||||
|
// every locale; the pre-existing timeline_grouping_date / timeline_tag_chip_label /
|
||||||
|
// timeline_filter_* set is reused, never re-added.
|
||||||
|
it('zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)', () => {
|
||||||
|
const requiredKeys = [
|
||||||
|
'timeline_grouping_event',
|
||||||
|
'timeline_grouping_thema',
|
||||||
|
'timeline_grouping_aria_label',
|
||||||
|
'timeline_grouping_segment_date',
|
||||||
|
'timeline_grouping_segment_event',
|
||||||
|
'timeline_grouping_segment_thema',
|
||||||
|
'timeline_grouping_segment_date_short',
|
||||||
|
'timeline_grouping_segment_event_short',
|
||||||
|
'timeline_grouping_segment_thema_short',
|
||||||
|
'timeline_grouping_disabled_reason',
|
||||||
|
'timeline_grouping_multitag_hint',
|
||||||
|
'timeline_bucket_other_letters',
|
||||||
|
'timeline_bucket_no_topic'
|
||||||
|
];
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
|
||||||
|
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
|
||||||
|
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
// the pre-existing meta-line + chip keys are reused by #827, not re-declared
|
||||||
|
expect(de).toHaveProperty('timeline_grouping_date');
|
||||||
|
expect(de).toHaveProperty('timeline_tag_chip_label');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-spouse',
|
relatedPersonId: 'p-spouse',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Otto Raddatz',
|
relatedPersonDisplayName: 'Otto Raddatz',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'r2',
|
id: 'r2',
|
||||||
@@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-friend',
|
relatedPersonId: 'p-friend',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Karl Friend',
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
relationType: 'FRIEND'
|
relationType: 'FRIEND',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'r3',
|
id: 'r3',
|
||||||
@@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-sibling',
|
relatedPersonId: 'p-sibling',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Marie Sister',
|
relatedPersonDisplayName: 'Marie Sister',
|
||||||
relationType: 'SIBLING_OF'
|
relationType: 'SIBLING_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-aug',
|
relatedPersonId: 'p-aug',
|
||||||
personDisplayName: 'Heinrich Raddatz',
|
personDisplayName: 'Heinrich Raddatz',
|
||||||
relatedPersonDisplayName: 'Auguste Raddatz',
|
relatedPersonDisplayName: 'Auguste Raddatz',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-friend',
|
relatedPersonId: 'p-friend',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Karl Friend',
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
relationType: 'FRIEND'
|
relationType: 'FRIEND',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
|
||||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
|
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
|
||||||
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
|
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
|
||||||
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
|
||||||
{ value: 'DAY', label: m.person_precision_day },
|
|
||||||
{ value: 'MONTH', label: m.person_precision_month },
|
|
||||||
{ value: 'YEAR', label: m.person_precision_year }
|
|
||||||
];
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
legend,
|
legend,
|
||||||
@@ -26,73 +19,21 @@ let {
|
|||||||
initialPrecision?: string | null;
|
initialPrecision?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let iso = $state('');
|
const precisions: { value: DatePrecision; label: string }[] = $derived([
|
||||||
let errorMessage = $state<string | null>(null);
|
{ value: 'DAY', label: m.person_precision_day() },
|
||||||
let inputEl = $state<HTMLInputElement | undefined>();
|
{ value: 'MONTH', label: m.person_precision_month() },
|
||||||
let precision = $state<DatePrecision>('DAY');
|
{ value: 'YEAR', label: m.person_precision_year() }
|
||||||
|
]);
|
||||||
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
|
const hint = $derived(`${m.person_precision_hint()} · ${m.person_date_placeholder_hint()}`);
|
||||||
// stomp the user's in-progress edit.
|
|
||||||
onMount(() => {
|
|
||||||
if (initialIso) {
|
|
||||||
iso = initialIso;
|
|
||||||
}
|
|
||||||
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
|
|
||||||
if (offered) {
|
|
||||||
precision = initialPrecision as DatePrecision;
|
|
||||||
} else if (initialIso) {
|
|
||||||
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
|
|
||||||
// untouched save does not silently claim DAY precision for the stored date.
|
|
||||||
precision = 'YEAR';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// A partial date leaves the hidden ISO empty — submitting then would silently
|
|
||||||
// clear a stored date. Block native submission until completed or fully emptied.
|
|
||||||
$effect(() => {
|
|
||||||
inputEl?.setCustomValidity(errorMessage ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlCls =
|
|
||||||
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset>
|
<DateInputWithPrecision
|
||||||
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{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}
|
name={name}
|
||||||
id={name}
|
legend={legend}
|
||||||
placeholder="TT.MM.JJJJ"
|
precisionLabel={precisionLabel}
|
||||||
ariaLabel={legend}
|
precisions={precisions}
|
||||||
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
|
hint={hint}
|
||||||
class={controlCls}
|
initialIso={initialIso}
|
||||||
/>
|
initialPrecision={initialPrecision}
|
||||||
{#if errorMessage}
|
selectClass="bg-surface"
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
|
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
|
||||||
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
||||||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
||||||
|
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -29,13 +30,15 @@ let {
|
|||||||
|
|
||||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
|
|
||||||
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
|
const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
|
||||||
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
||||||
|
let editingRelId = $state<string | null>(null);
|
||||||
|
|
||||||
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
|
function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number {
|
||||||
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
||||||
if (order !== 0) return order;
|
if (order !== 0) return order;
|
||||||
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
|
// ISO dates sort lexicographically == chronologically; a missing date sorts first.
|
||||||
|
return (a.fromDate ?? '').localeCompare(b.fromDate ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationTypeOrder(t: RelationType | undefined): number {
|
function relationTypeOrder(t: RelationType | undefined): number {
|
||||||
@@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number {
|
|||||||
return order[t ?? 'OTHER'] ?? 99;
|
return order[t ?? 'OTHER'] ?? 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
function yearRange(rel: RelationshipDTO): string {
|
function dateRangeOf(rel: RelationshipDTO): string {
|
||||||
const from = rel.fromYear;
|
return formatRelationshipDateRange(
|
||||||
const to = rel.toYear;
|
rel.fromDate,
|
||||||
if (from && to) return `${from}–${to}`;
|
rel.fromDatePrecision,
|
||||||
if (from) return m.relation_year_from({ year: from });
|
rel.toDate,
|
||||||
if (to) return m.relation_year_to({ year: to });
|
rel.toDatePrecision
|
||||||
return '';
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string {
|
|||||||
<RelationshipChip
|
<RelationshipChip
|
||||||
chipLabel={chipLabel(rel, personId)}
|
chipLabel={chipLabel(rel, personId)}
|
||||||
otherName={otherName(rel, personId)}
|
otherName={otherName(rel, personId)}
|
||||||
yearRange={yearRange(rel)}
|
dateRange={dateRangeOf(rel)}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
relId={rel.id}
|
relId={rel.id}
|
||||||
|
onEdit={canWrite ? () => (editingRelId = rel.id) : undefined}
|
||||||
/>
|
/>
|
||||||
|
{#if editingRelId === rel.id}
|
||||||
|
<li>
|
||||||
|
<AddRelationshipForm
|
||||||
|
personId={personId}
|
||||||
|
relationship={rel}
|
||||||
|
onClose={() => (editingRelId = null)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
|
|||||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the year range "from–to" for a relationship with both years', async () => {
|
it('renders the date range "from – to" for a relationship with both dates', async () => {
|
||||||
render(StammbaumCard, {
|
render(StammbaumCard, {
|
||||||
props: baseProps({
|
props: baseProps({
|
||||||
relationships: [
|
relationships: [
|
||||||
{
|
{
|
||||||
id: 'r-1',
|
id: 'r-1',
|
||||||
|
personId: 'p-1',
|
||||||
|
relatedPersonId: 'p-x',
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Xavier',
|
||||||
relationType: 'COLLEAGUE',
|
relationType: 'COLLEAGUE',
|
||||||
fromYear: 1940,
|
fromDate: '1940-01-01',
|
||||||
toYear: 1945,
|
fromDatePrecision: 'YEAR',
|
||||||
personA: { id: 'p-1', displayName: 'Anna' },
|
toDate: '1945-01-01',
|
||||||
personB: { id: 'p-x', displayName: 'Xavier' }
|
toDatePrecision: 'YEAR'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -131,23 +135,27 @@ describe('StammbaumCard', () => {
|
|||||||
expect(document.body.textContent).toContain('1945');
|
expect(document.body.textContent).toContain('1945');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders only "fromYear" for a relationship with no end year', async () => {
|
it('renders only the start date for a relationship with no end date', async () => {
|
||||||
render(StammbaumCard, {
|
render(StammbaumCard, {
|
||||||
props: baseProps({
|
props: baseProps({
|
||||||
relationships: [
|
relationships: [
|
||||||
{
|
{
|
||||||
id: 'r-2',
|
id: 'r-2',
|
||||||
|
personId: 'p-1',
|
||||||
|
relatedPersonId: 'p-y',
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Yvonne',
|
||||||
relationType: 'NEIGHBOR',
|
relationType: 'NEIGHBOR',
|
||||||
fromYear: 1935,
|
fromDate: '1935-01-01',
|
||||||
personA: { id: 'p-1', displayName: 'Anna' },
|
fromDatePrecision: 'YEAR',
|
||||||
personB: { id: 'p-y', displayName: 'Yvonne' }
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('1935');
|
expect(document.body.textContent).toContain('1935');
|
||||||
expect(document.body.textContent).not.toContain('1935–');
|
expect(document.body.textContent).not.toContain('1935 –');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the inferred-relationships disclosure when topDerived has items', async () => {
|
it('renders the inferred-relationships disclosure when topDerived has items', async () => {
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
y2={bCenter.y}
|
y2={bCenter.y}
|
||||||
stroke="var(--c-primary)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
stroke-dasharray={e.toDate ? '4 4' : undefined}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={(aCenter.x + bCenter.x) / 2}
|
cx={(aCenter.x + bCenter.x) / 2}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
|
|||||||
relatedPersonId: childId,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
|
|||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF',
|
relationType: 'SPOUSE_OF',
|
||||||
toYear: 1950
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDate: '1950-01-01',
|
||||||
|
toDatePrecision: 'YEAR'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,19 @@ async function loadFor(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddRelationship(data: RelFormData) {
|
async function handleAddRelationship(data: RelFormData) {
|
||||||
const body: Record<string, string | number> = {
|
const body: Record<string, string> = {
|
||||||
relatedPersonId: data.relatedPersonId,
|
relatedPersonId: data.relatedPersonId,
|
||||||
relationType: data.relationType
|
relationType: data.relationType
|
||||||
};
|
};
|
||||||
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
|
if (data.fromDate) {
|
||||||
if (data.toYear !== undefined) body.toYear = data.toYear;
|
body.fromDate = data.fromDate;
|
||||||
|
if (data.fromDatePrecision) body.fromDatePrecision = data.fromDatePrecision;
|
||||||
|
}
|
||||||
|
if (data.toDate) {
|
||||||
|
body.toDate = data.toDate;
|
||||||
|
if (data.toDatePrecision) body.toDatePrecision = data.toDatePrecision;
|
||||||
|
}
|
||||||
|
if (data.notes) body.notes = data.notes;
|
||||||
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
|
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => {
|
|||||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('year inputs inside the add form have label elements (canWrite=true)', async () => {
|
it('date inputs inside the add form have accessible labels (canWrite=true)', async () => {
|
||||||
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
|
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
|
||||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
||||||
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
||||||
);
|
);
|
||||||
addBtn!.click();
|
addBtn!.click();
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const yearInputs = [...document.querySelectorAll('input')].filter(
|
const dateInputs = [...document.querySelectorAll('input')].filter(
|
||||||
(i) => i.inputMode === 'numeric'
|
(i) => i.inputMode === 'numeric'
|
||||||
);
|
);
|
||||||
expect(yearInputs.length).toBeGreaterThan(0);
|
expect(dateInputs.length).toBeGreaterThan(0);
|
||||||
for (const input of yearInputs) {
|
for (const input of dateInputs) {
|
||||||
expect(input.closest('label')).not.toBeNull();
|
expect(input.getAttribute('aria-label')).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte';
|
|||||||
import StammbaumTree from './StammbaumTree.svelte';
|
import StammbaumTree from './StammbaumTree.svelte';
|
||||||
import type { PanZoomState } from './panZoom';
|
import type { PanZoomState } from './panZoom';
|
||||||
import { DIMMED_OPACITY } from './layout/highlightLineage';
|
import { DIMMED_OPACITY } from './layout/highlightLineage';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
const ID_A = '00000000-0000-0000-0000-000000000001';
|
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||||
@@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: PARENT_B,
|
relatedPersonId: PARENT_B,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1a',
|
id: 'p1a',
|
||||||
@@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_1,
|
relatedPersonId: CHILD_1,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1b',
|
id: 'p1b',
|
||||||
@@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_1,
|
relatedPersonId: CHILD_1,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2a',
|
id: 'p2a',
|
||||||
@@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_2,
|
relatedPersonId: CHILD_2,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2b',
|
id: 'p2b',
|
||||||
@@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_2,
|
relatedPersonId: CHILD_2,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: PARENT_B,
|
relatedPersonId: PARENT_B,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: EUGENIE,
|
relatedPersonId: EUGENIE,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3',
|
||||||
@@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p4',
|
id: 'p4',
|
||||||
@@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's2',
|
id: 's2',
|
||||||
@@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HILDE,
|
relatedPersonId: HILDE,
|
||||||
personDisplayName: 'Hans',
|
personDisplayName: 'Hans',
|
||||||
relatedPersonDisplayName: 'Hilde',
|
relatedPersonDisplayName: 'Hilde',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p5',
|
id: 'p5',
|
||||||
@@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: LILI,
|
relatedPersonId: LILI,
|
||||||
personDisplayName: 'Hans',
|
personDisplayName: 'Hans',
|
||||||
relatedPersonDisplayName: 'Lili',
|
relatedPersonDisplayName: 'Lili',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p6',
|
id: 'p6',
|
||||||
@@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: LILI,
|
relatedPersonId: LILI,
|
||||||
personDisplayName: 'Hilde',
|
personDisplayName: 'Hilde',
|
||||||
relatedPersonDisplayName: 'Lili',
|
relatedPersonDisplayName: 'Lili',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF',
|
relationType: 'SPOUSE_OF',
|
||||||
toYear: 1925
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDate: '1925-01-01',
|
||||||
|
toDatePrecision: 'YEAR'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Parent',
|
personDisplayName: 'Parent',
|
||||||
relatedPersonDisplayName: 'Child',
|
relatedPersonDisplayName: 'Child',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
personDisplayName: string;
|
personDisplayName: string;
|
||||||
relatedPersonDisplayName: string;
|
relatedPersonDisplayName: string;
|
||||||
relationType: 'PARENT_OF' | 'SPOUSE_OF';
|
relationType: 'PARENT_OF' | 'SPOUSE_OF';
|
||||||
|
fromDatePrecision: 'UNKNOWN';
|
||||||
|
toDatePrecision: 'UNKNOWN';
|
||||||
};
|
};
|
||||||
const edge = (
|
const edge = (
|
||||||
personId: string,
|
personId: string,
|
||||||
@@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
relatedPersonId,
|
relatedPersonId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType
|
relationType,
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
});
|
});
|
||||||
|
|
||||||
const NODES = [
|
const NODES = [
|
||||||
@@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
// year, then a deterministic id tie-break), not alphabetically — with no birth
|
// year, then a deterministic id tie-break), not alphabetically — with no birth
|
||||||
// years here Walter (id …a1) owns the run and Eugenie sits to his right. So the
|
// years here Walter (id …a1) owns the run and Eugenie sits to his right. So the
|
||||||
// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
|
// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
|
||||||
const FAMILY_EDGES = [
|
const FAMILY_EDGES: RelationshipDTO[] = [
|
||||||
{
|
{
|
||||||
id: 'sp',
|
id: 'sp',
|
||||||
personId: WALTER,
|
personId: WALTER,
|
||||||
relatedPersonId: EUGENIE,
|
relatedPersonId: EUGENIE,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3',
|
||||||
@@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p4',
|
id: 'p4',
|
||||||
@@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
|
|||||||
relatedPersonId: childId,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
|||||||
relatedPersonId: b,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
|
|||||||
fromYear: number | undefined,
|
fromYear: number | undefined,
|
||||||
id = a + b
|
id = a + b
|
||||||
): RelationshipDTO {
|
): RelationshipDTO {
|
||||||
return { ...spouseEdge(a, b, id), fromYear };
|
return {
|
||||||
|
...spouseEdge(a, b, id),
|
||||||
|
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
|
it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
|
||||||
@@ -329,7 +336,7 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
|
|||||||
// fail fast instead so the maintainer either updates the test or
|
// fail fast instead so the maintainer either updates the test or
|
||||||
// splits into a year-branch / name-branch pair.
|
// splits into a year-branch / name-branch pair.
|
||||||
const spouseEdgesWithYear = fixtureEdges.filter(
|
const spouseEdgesWithYear = fixtureEdges.filter(
|
||||||
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null
|
(e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
spouseEdgesWithYear,
|
spouseEdgesWithYear,
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO {
|
|||||||
relatedPersonId: c,
|
relatedPersonId: c,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +35,9 @@ function spouse(a: string, b: string, fromYear?: number): RelationshipDTO {
|
|||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF',
|
relationType: 'SPOUSE_OF',
|
||||||
...(fromYear != null ? { fromYear } : {})
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN',
|
||||||
|
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO
|
|||||||
} else if (e.relationType === 'SPOUSE_OF') {
|
} else if (e.relationType === 'SPOUSE_OF') {
|
||||||
addToSet(spouses, e.personId, e.relatedPersonId);
|
addToSet(spouses, e.personId, e.relatedPersonId);
|
||||||
addToSet(spouses, e.relatedPersonId, e.personId);
|
addToSet(spouses, e.relatedPersonId, e.personId);
|
||||||
spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined);
|
spouseYear.set(
|
||||||
|
pairKey(e.personId, e.relatedPersonId),
|
||||||
|
e.fromDate ? Number(e.fromDate.slice(0, 4)) : undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
|
|||||||
relatedPersonId: childId,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +26,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
|||||||
relatedPersonId: b,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,9 @@ function siblingEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
|||||||
relatedPersonId: b,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SIBLING_OF'
|
relationType: 'SIBLING_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats one life date (birth or death) at the precision the data claims,
|
* Formats one life date (birth or death) at the precision the data claims.
|
||||||
* delegating all rendering to {@link formatDocumentDate}. Returns '' for a
|
* Thin domain alias over the shared {@link formatDatePart}: carries no * / †
|
||||||
* missing date. Carries no * / † glyph — components that need the glyphs wrap
|
* glyph — components that need the glyphs wrap them in their own `aria-hidden`
|
||||||
* them in their own `aria-hidden` markup so screen readers only hear the date.
|
* markup so screen readers only hear the date.
|
||||||
*
|
|
||||||
* A missing precision falls back to YEAR: pre-V76 rows only knew a year, and
|
|
||||||
* a bare year is the only safe rendering for a date without precision metadata.
|
|
||||||
*/
|
*/
|
||||||
export function formatLifeDate(
|
export function formatLifeDate(
|
||||||
date: string | null | undefined,
|
date: string | null | undefined,
|
||||||
precision: DatePrecision | null | undefined,
|
precision: DatePrecision | null | undefined,
|
||||||
locale?: string
|
locale?: string
|
||||||
): string {
|
): string {
|
||||||
if (!date) {
|
return formatDatePart(date, precision, locale);
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||||
|
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
@@ -10,71 +13,96 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
|||||||
export type RelFormData = {
|
export type RelFormData = {
|
||||||
relatedPersonId: string;
|
relatedPersonId: string;
|
||||||
relationType: RelationType;
|
relationType: RelationType;
|
||||||
fromYear?: number;
|
fromDate?: string;
|
||||||
toYear?: number;
|
fromDatePrecision?: DatePrecision;
|
||||||
|
toDate?: string;
|
||||||
|
toDatePrecision?: DatePrecision;
|
||||||
|
notes?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
personId: string;
|
personId: string;
|
||||||
|
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
|
||||||
|
relationship?: RelationshipDTO;
|
||||||
onSubmit?: (data: RelFormData) => Promise<void>;
|
onSubmit?: (data: RelFormData) => Promise<void>;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { personId, onSubmit }: Props = $props();
|
let { personId, relationship, onSubmit, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const isEdit = $derived(relationship != null);
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let addType = $state<RelationType>('PARENT_OF');
|
let addType = $state<RelationType>('PARENT_OF');
|
||||||
let addRelatedPersonId = $state('');
|
let addRelatedPersonId = $state('');
|
||||||
let addRelatedPersonName = $state('');
|
let addRelatedPersonName = $state('');
|
||||||
let addFromYear = $state('');
|
let notes = $state('');
|
||||||
let addToYear = $state('');
|
|
||||||
let callbackError = $state<string | null>(null);
|
let callbackError = $state<string | null>(null);
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
const yearError = $derived.by(() => {
|
// Seed once at mount (reading props in a closure avoids state_referenced_locally).
|
||||||
const from = addFromYear.trim();
|
// The parent re-creates this form per edited row, so the relationship never
|
||||||
const to = addToYear.trim();
|
// changes under a live instance.
|
||||||
if (!from || !to) return null;
|
onMount(() => {
|
||||||
const fromInt = parseInt(from, 10);
|
if (!relationship) return;
|
||||||
const toInt = parseInt(to, 10);
|
open = true;
|
||||||
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
|
addType = relationship.relationType ?? 'PARENT_OF';
|
||||||
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
|
const viewpointIsSubject = relationship.personId === personId;
|
||||||
|
addRelatedPersonId =
|
||||||
|
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
|
||||||
|
addRelatedPersonName =
|
||||||
|
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
|
||||||
|
'';
|
||||||
|
notes = relationship.notes ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const selfError = $derived(
|
const selfError = $derived(
|
||||||
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitDisabled = $derived(
|
const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
|
||||||
yearError !== null || selfError !== null || addRelatedPersonId === ''
|
|
||||||
);
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
addType = 'PARENT_OF';
|
addType = 'PARENT_OF';
|
||||||
addRelatedPersonId = '';
|
addRelatedPersonId = '';
|
||||||
addRelatedPersonName = '';
|
addRelatedPersonName = '';
|
||||||
addFromYear = '';
|
notes = '';
|
||||||
addToYear = '';
|
|
||||||
callbackError = null;
|
callbackError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
|
if (isEdit) {
|
||||||
|
onClose?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
open = false;
|
open = false;
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCallbackSubmit(event: Event) {
|
async function handleCallbackSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (submitDisabled || !onSubmit) return;
|
if (submitDisabled || !onSubmit) return;
|
||||||
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
|
const fd = new FormData(event.currentTarget as HTMLFormElement);
|
||||||
const from = parseInt(addFromYear.trim(), 10);
|
const fromDate = (fd.get('fromDate') as string) || undefined;
|
||||||
if (!Number.isNaN(from)) data.fromYear = from;
|
const toDate = (fd.get('toDate') as string) || undefined;
|
||||||
const to = parseInt(addToYear.trim(), 10);
|
const data: RelFormData = {
|
||||||
if (!Number.isNaN(to)) data.toYear = to;
|
relatedPersonId: addRelatedPersonId,
|
||||||
|
relationType: addType,
|
||||||
|
fromDate,
|
||||||
|
fromDatePrecision: fromDate ? (fd.get('fromDatePrecision') as DatePrecision) : undefined,
|
||||||
|
toDate,
|
||||||
|
toDatePrecision: toDate ? (fd.get('toDatePrecision') as DatePrecision) : undefined,
|
||||||
|
notes: (fd.get('notes') as string)?.trim() || undefined
|
||||||
|
};
|
||||||
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
await onSubmit(data);
|
await onSubmit(data);
|
||||||
open = false;
|
open = false;
|
||||||
reset();
|
reset();
|
||||||
} catch {
|
} catch {
|
||||||
callbackError = m.error_internal_error();
|
callbackError = m.error_internal_error();
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label class="block">
|
|
||||||
<span class="font-sans text-xs font-medium text-ink-2"
|
|
||||||
>{m.relation_form_field_from_year()}</span
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="fromYear"
|
|
||||||
inputmode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
bind:value={addFromYear}
|
|
||||||
placeholder={m.relation_form_year_placeholder()}
|
|
||||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="toYear"
|
|
||||||
inputmode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
bind:value={addToYear}
|
|
||||||
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
|
|
||||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
{#if yearError}
|
|
||||||
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
|
|
||||||
{yearError}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
|
<RelationshipDateField
|
||||||
|
name="fromDate"
|
||||||
|
legend={m.relation_label_from_date()}
|
||||||
|
initialIso={relationship?.fromDate ?? ''}
|
||||||
|
initialPrecision={relationship?.fromDatePrecision ?? null}
|
||||||
|
/>
|
||||||
|
<RelationshipDateField
|
||||||
|
name="toDate"
|
||||||
|
legend={m.relation_label_to_date()}
|
||||||
|
initialIso={relationship?.toDate ?? ''}
|
||||||
|
initialPrecision={relationship?.toDatePrecision ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="mt-3 block">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_notes()}</span>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
maxlength="2000"
|
||||||
|
rows="2"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder={m.relation_notes_placeholder()}
|
||||||
|
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 font-serif text-sm text-ink-3 focus:border-primary focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
{#if selfError}
|
{#if selfError}
|
||||||
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitDisabled}
|
disabled={submitDisabled || submitting}
|
||||||
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
|
aria-busy={submitting}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{m.relation_btn_add()}
|
{#if submitting}
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 animate-spin rounded-full border-2 border-primary-fg/40 border-t-primary-fg"
|
||||||
|
data-testid="submit-spinner"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
{isEdit ? m.relation_btn_save() : m.relation_btn_add()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) {
|
|||||||
{:else}
|
{:else}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/addRelationship"
|
action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
|
submitting = true;
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
await update();
|
await update();
|
||||||
|
submitting = false;
|
||||||
if (result.type === 'success') {
|
if (result.type === 'success') {
|
||||||
|
if (isEdit) {
|
||||||
|
onClose?.();
|
||||||
|
} else {
|
||||||
open = false;
|
open = false;
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
|
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
|
||||||
>
|
>
|
||||||
|
{#if relationship}
|
||||||
|
<input type="hidden" name="relId" value={relationship.id} />
|
||||||
|
{/if}
|
||||||
{@render formFields()}
|
{@render formFields()}
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('AddRelationshipForm', () => {
|
const PID = 'person-1';
|
||||||
it('shows add-relationship button initially and no form', async () => {
|
const OTHER = 'person-2';
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
|
||||||
|
const editRel = () => ({
|
||||||
|
id: 'rel-9',
|
||||||
|
personId: PID,
|
||||||
|
relatedPersonId: OTHER,
|
||||||
|
personDisplayName: 'Anna Müller',
|
||||||
|
relatedPersonDisplayName: 'Hans Müller',
|
||||||
|
relationType: 'SPOUSE_OF' as const,
|
||||||
|
fromDate: '1923-05-12',
|
||||||
|
fromDatePrecision: 'DAY' as const,
|
||||||
|
toDatePrecision: 'UNKNOWN' as const,
|
||||||
|
notes: 'Hochzeit in Berlin'
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AddRelationshipForm — create mode', () => {
|
||||||
|
it('shows the add-relationship toggle initially and no form', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: PID });
|
||||||
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
expect(document.querySelector('select[name="relationType"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows relationType select when add button is clicked', async () => {
|
it('shows the relationType select when the add toggle is clicked', async () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
render(AddRelationshipForm, { personId: PID });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides form and shows button when cancel is clicked', async () => {
|
it('hides the form and shows the toggle again on cancel', async () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
render(AddRelationshipForm, { personId: PID });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
||||||
);
|
);
|
||||||
cancelBtn!.click();
|
cancelBtn!.click();
|
||||||
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
await vi.waitFor(() =>
|
||||||
|
expect(document.querySelector('select[name="relationType"]')).toBeNull()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submit is disabled when no person is selected', async () => {
|
it('disables submit when no person is selected', async () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
render(AddRelationshipForm, { personId: PID });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
|
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('form has no server action when onSubmit prop is provided', async () => {
|
it('has no server action when an onSubmit prop is provided', async () => {
|
||||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
|
render(AddRelationshipForm, { personId: PID, onSubmit });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const form = document.querySelector('form');
|
expect(document.querySelector('form')?.hasAttribute('action')).toBe(false);
|
||||||
expect(form?.hasAttribute('action')).toBe(false);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows year-range error when toYear is before fromYear', async () => {
|
describe('AddRelationshipForm — edit mode', () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
it('opens pre-filled and labels the submit "Speichern"', async () => {
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
|
|
||||||
fromInput.value = '1935';
|
it('pre-fills the from-date as dd.mm.yyyy', async () => {
|
||||||
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
|
||||||
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
|
const fromInput = document.querySelector<HTMLInputElement>('#fromDate')!;
|
||||||
toInput.value = '1920';
|
await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923'));
|
||||||
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
});
|
||||||
|
|
||||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
it('round-trips the notes into the textarea', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
|
||||||
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
|
const notes = document.querySelector<HTMLTextAreaElement>('textarea[name="notes"]')!;
|
||||||
|
await vi.waitFor(() => expect(notes.value).toBe('Hochzeit in Berlin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only DAY/MONTH/YEAR in each precision select', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
|
||||||
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
|
const options = [
|
||||||
|
...document.querySelectorAll<HTMLOptionElement>('#fromDatePrecision option')
|
||||||
|
].map((o) => o.value);
|
||||||
|
expect(options).toEqual(['DAY', 'MONTH', 'YEAR']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives each date input an associated label (accessible name)', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
|
||||||
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('#fromDate')?.getAttribute('aria-label')).toBe('Beginn (Datum)');
|
||||||
|
expect(document.querySelector('#toDate')?.getAttribute('aria-label')).toBe('Ende (Datum)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the submit and shows a progress spinner while a submit is in flight', async () => {
|
||||||
|
let resolve: () => void = () => {};
|
||||||
|
const onSubmit = vi.fn(() => new Promise<void>((r) => (resolve = r)));
|
||||||
|
render(AddRelationshipForm, { personId: PID, relationship: editRel(), onSubmit });
|
||||||
|
|
||||||
|
const submit = await vi.waitFor(() => {
|
||||||
|
const b = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
(x) => x.type === 'submit'
|
||||||
|
);
|
||||||
|
if (!b) throw new Error('submit not ready');
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
submit.click();
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('submit-spinner')).toBeInTheDocument();
|
||||||
|
await vi.waitFor(() => expect(submit.disabled).toBe(true));
|
||||||
|
expect(onSubmit).toHaveBeenCalledOnce();
|
||||||
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,36 +33,6 @@ describe('AddRelationshipForm', () => {
|
|||||||
expect(optionValues).toContain('OTHER');
|
expect(optionValues).toContain('OTHER');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the year-error alert when toYear is before fromYear', async () => {
|
|
||||||
render(AddRelationshipForm, { props: { personId: 'p-1' } });
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /hinzufügen/i }).click();
|
|
||||||
|
|
||||||
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
|
|
||||||
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
|
|
||||||
fromInput.value = '1923';
|
|
||||||
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
toInput.value = '1920';
|
|
||||||
toInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
|
|
||||||
await expect.element(page.getByText(/bis-jahr darf nicht vor von-jahr/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show the year-error when toYear equals fromYear', async () => {
|
|
||||||
render(AddRelationshipForm, { props: { personId: 'p-1' } });
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /hinzufügen/i }).click();
|
|
||||||
|
|
||||||
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
|
|
||||||
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
|
|
||||||
fromInput.value = '1923';
|
|
||||||
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
toInput.value = '1923';
|
|
||||||
toInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
|
|
||||||
await expect.element(page.getByText(/bis-jahr darf nicht/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancel button closes the form', async () => {
|
it('cancel button closes the form', async () => {
|
||||||
render(AddRelationshipForm, { props: { personId: 'p-1' } });
|
render(AddRelationshipForm, { props: { personId: 'p-1' } });
|
||||||
|
|
||||||
@@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => {
|
|||||||
expect(submitBtn!.disabled).toBe(true);
|
expect(submitBtn!.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps submit disabled when there is a yearError', async () => {
|
|
||||||
render(AddRelationshipForm, { props: { personId: 'p-1' } });
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /hinzufügen/i }).click();
|
|
||||||
|
|
||||||
const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement;
|
|
||||||
const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement;
|
|
||||||
const relInput = document.querySelector('input[name="relatedPersonId"]') as HTMLInputElement;
|
|
||||||
fromInput.value = '1923';
|
|
||||||
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
toInput.value = '1920';
|
|
||||||
toInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
relInput.value = 'p-other';
|
|
||||||
relInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
|
||||||
expect(submitBtn.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
interface Props {
|
interface Props {
|
||||||
chipLabel: string;
|
chipLabel: string;
|
||||||
otherName: string;
|
otherName: string;
|
||||||
yearRange?: string;
|
dateRange?: string;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
relId: string;
|
relId: string;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
|
let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li class="flex items-center gap-2 py-2">
|
<li class="flex items-center gap-2 py-2">
|
||||||
@@ -22,8 +23,31 @@ let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
|
|||||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||||
{otherName}
|
{otherName}
|
||||||
</span>
|
</span>
|
||||||
{#if yearRange}
|
{#if dateRange}
|
||||||
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
|
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="date-range">{dateRange}</span>
|
||||||
|
{/if}
|
||||||
|
{#if canWrite && onEdit}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onEdit}
|
||||||
|
aria-label="{m.relation_edit()} — {otherName}"
|
||||||
|
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if canWrite}
|
{#if canWrite}
|
||||||
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
|
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ afterEach(cleanup);
|
|||||||
const baseProps = {
|
const baseProps = {
|
||||||
chipLabel: 'Elternteil',
|
chipLabel: 'Elternteil',
|
||||||
otherName: 'Anna Schmidt',
|
otherName: 'Anna Schmidt',
|
||||||
yearRange: '',
|
dateRange: '',
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
relId: 'rel-1'
|
relId: 'rel-1'
|
||||||
};
|
};
|
||||||
@@ -26,30 +26,55 @@ describe('RelationshipChip', () => {
|
|||||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows year range when provided', async () => {
|
it('shows the date range when provided', async () => {
|
||||||
render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' });
|
render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 – 1958' });
|
||||||
await expect.element(page.getByText('1920–1980')).toBeInTheDocument();
|
await expect.element(page.getByText('12. Mai 1923 – 1958')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show year range span when empty', async () => {
|
it('does not render a date-range span when empty', async () => {
|
||||||
render(RelationshipChip, { ...baseProps, yearRange: '' });
|
render(RelationshipChip, { ...baseProps, dateRange: '' });
|
||||||
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
|
expect(document.querySelector('[data-testid="date-range"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows delete button when canWrite is true', async () => {
|
it('shows the delete button when canWrite is true', async () => {
|
||||||
render(RelationshipChip, { ...baseProps, canWrite: true });
|
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||||
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides delete button when canWrite is false', async () => {
|
it('hides the delete button when canWrite is false', async () => {
|
||||||
render(RelationshipChip, { ...baseProps, canWrite: false });
|
render(RelationshipChip, { ...baseProps, canWrite: false });
|
||||||
expect(document.querySelector('button')).toBeNull();
|
expect(document.querySelector('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => {
|
it('gives the delete button an h-11 w-11 (44px) WCAG touch target', async () => {
|
||||||
render(RelationshipChip, { ...baseProps, canWrite: true });
|
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||||
const btn = document.querySelector('button')!;
|
const btn = document.querySelector('button')!;
|
||||||
expect(btn.className).toContain('h-11');
|
expect(btn.className).toContain('h-11');
|
||||||
expect(btn.className).toContain('w-11');
|
expect(btn.className).toContain('w-11');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an Edit affordance with an accessible name when canWrite and onEdit', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, canWrite: true, onEdit: () => {} });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Beziehung bearbeiten/i }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the Edit affordance without onEdit', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||||
|
expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the Edit affordance when canWrite is false', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, canWrite: false, onEdit: () => {} });
|
||||||
|
expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onEdit when the Edit affordance is clicked', async () => {
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
render(RelationshipChip, { ...baseProps, canWrite: true, onEdit });
|
||||||
|
const editBtn = document.querySelector<HTMLButtonElement>('button[aria-label*="bearbeiten"]')!;
|
||||||
|
editBtn.click();
|
||||||
|
expect(onEdit).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
|
// Only DAY / MONTH / YEAR are offered (same as the person life-date field and the
|
||||||
|
// 60+ author audience). Storage still accepts all seven precisions; SEASON/RANGE/
|
||||||
|
// APPROX render correctly elsewhere but make no sense to enter for a relationship.
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
legend,
|
||||||
|
initialIso = '',
|
||||||
|
initialPrecision = null
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
legend: string;
|
||||||
|
initialIso?: string | null;
|
||||||
|
initialPrecision?: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const precisions: { value: DatePrecision; label: string }[] = $derived([
|
||||||
|
{ value: 'DAY', label: m.relation_precision_day() },
|
||||||
|
{ value: 'MONTH', label: m.relation_precision_month() },
|
||||||
|
{ value: 'YEAR', label: m.relation_precision_year() }
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DateInputWithPrecision
|
||||||
|
name={name}
|
||||||
|
legend={legend}
|
||||||
|
precisionLabel={m.relation_label_date_precision()}
|
||||||
|
precisions={precisions}
|
||||||
|
hint={m.relation_date_placeholder_hint()}
|
||||||
|
initialIso={initialIso}
|
||||||
|
initialPrecision={initialPrecision}
|
||||||
|
inputClass="bg-surface"
|
||||||
|
selectClass="bg-surface text-ink-3"
|
||||||
|
/>
|
||||||
65
frontend/src/lib/person/relationshipDates.spec.ts
Normal file
65
frontend/src/lib/person/relationshipDates.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatRelationshipDateRange } from './relationshipDates';
|
||||||
|
|
||||||
|
// Delegates all precision rendering to formatDocumentDate — these tests pin the
|
||||||
|
// composition (dash, single sides, empty state) and one rendering per precision,
|
||||||
|
// plus en/es for DAY/MONTH so a German-month leak is caught here, not on a card.
|
||||||
|
describe('formatRelationshipDateRange', () => {
|
||||||
|
describe('both dates (de default)', () => {
|
||||||
|
it('renders DAY precision as full dates', () => {
|
||||||
|
expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-06-13', 'DAY')).toBe(
|
||||||
|
'12. Mai 1923 – 13. Juni 1958'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders MONTH precision as month + year', () => {
|
||||||
|
expect(formatRelationshipDateRange('1923-05-01', 'MONTH', '1958-06-01', 'MONTH')).toBe(
|
||||||
|
'Mai 1923 – Juni 1958'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders YEAR precision as bare years', () => {
|
||||||
|
expect(formatRelationshipDateRange('1923-01-01', 'YEAR', '1958-01-01', 'YEAR')).toBe(
|
||||||
|
'1923 – 1958'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders mixed precisions per side', () => {
|
||||||
|
expect(formatRelationshipDateRange('1923-05-12', 'DAY', '1958-01-01', 'YEAR')).toBe(
|
||||||
|
'12. Mai 1923 – 1958'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('single sides and empty states', () => {
|
||||||
|
it('renders from only without a trailing dash', () => {
|
||||||
|
expect(formatRelationshipDateRange('1923-05-12', 'DAY', null, null)).toBe('12. Mai 1923');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders to only with a leading dash', () => {
|
||||||
|
expect(formatRelationshipDateRange(null, null, '1958-06-13', 'DAY')).toBe('– 13. Juni 1958');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when both dates are missing (UNKNOWN)', () => {
|
||||||
|
expect(formatRelationshipDateRange(null, 'UNKNOWN', null, 'UNKNOWN')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for a from-only with a null date', () => {
|
||||||
|
expect(formatRelationshipDateRange(null, null, null, null)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localized months (catch German-month leak)', () => {
|
||||||
|
it('renders DAY in English with no German month name', () => {
|
||||||
|
const out = formatRelationshipDateRange('1923-05-12', 'DAY', null, null, 'en');
|
||||||
|
expect(out).toContain('May');
|
||||||
|
expect(out).not.toContain('Mai');
|
||||||
|
expect(out).toContain('1923');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders MONTH in Spanish', () => {
|
||||||
|
const out = formatRelationshipDateRange('1923-05-01', 'MONTH', null, null, 'es');
|
||||||
|
expect(out.toLowerCase()).toContain('mayo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
30
frontend/src/lib/person/relationshipDates.ts
Normal file
30
frontend/src/lib/person/relationshipDates.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a relationship's start–end range as plain text, e.g. for a marriage row.
|
||||||
|
* Examples (de):
|
||||||
|
* 12. Mai 1923 – 13. Juni 1958 (both)
|
||||||
|
* 12. Mai 1923 (start only — no trailing dash)
|
||||||
|
* – 13. Juni 1958 (end only)
|
||||||
|
* "" (neither — the caller renders no date line)
|
||||||
|
*/
|
||||||
|
export function formatRelationshipDateRange(
|
||||||
|
fromDate: string | null | undefined,
|
||||||
|
fromDatePrecision: DatePrecision | null | undefined,
|
||||||
|
toDate: string | null | undefined,
|
||||||
|
toDatePrecision: DatePrecision | null | undefined,
|
||||||
|
locale?: string
|
||||||
|
): string {
|
||||||
|
const from = formatDatePart(fromDate, fromDatePrecision, locale);
|
||||||
|
const to = formatDatePart(toDate, toDatePrecision, locale);
|
||||||
|
if (from && to) {
|
||||||
|
return `${from} – ${to}`;
|
||||||
|
}
|
||||||
|
if (from) {
|
||||||
|
return from;
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
return `– ${to}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ function makeRel(
|
|||||||
personDisplayName: 'Alice',
|
personDisplayName: 'Alice',
|
||||||
relatedPersonDisplayName: 'Bob',
|
relatedPersonDisplayName: 'Bob',
|
||||||
relationType,
|
relationType,
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN',
|
||||||
...override
|
...override
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ErrorCode =
|
|||||||
| 'INVALID_PERSON_TYPE'
|
| 'INVALID_PERSON_TYPE'
|
||||||
| 'BIRTH_AFTER_DEATH'
|
| 'BIRTH_AFTER_DEATH'
|
||||||
| 'INVALID_DATE_PRECISION'
|
| 'INVALID_DATE_PRECISION'
|
||||||
|
| 'INVALID_RELATIONSHIP_DATES'
|
||||||
| 'INVALID_DATE_RANGE'
|
| 'INVALID_DATE_RANGE'
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
@@ -106,6 +107,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_birth_after_death();
|
return m.error_birth_after_death();
|
||||||
case 'INVALID_DATE_PRECISION':
|
case 'INVALID_DATE_PRECISION':
|
||||||
return m.error_invalid_date_precision();
|
return m.error_invalid_date_precision();
|
||||||
|
case 'INVALID_RELATIONSHIP_DATES':
|
||||||
|
return m.error_invalid_relationship_dates();
|
||||||
case 'INVALID_DATE_RANGE':
|
case 'INVALID_DATE_RANGE':
|
||||||
return m.error_invalid_date_range();
|
return m.error_invalid_date_range();
|
||||||
case 'DOCUMENT_NOT_FOUND':
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
|
|||||||
106
frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte
Normal file
106
frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact date + precision field: the {@link DateInput} primitive paired with a
|
||||||
|
* precision <select> offering a caller-chosen subset of precisions. Shared base of
|
||||||
|
* PersonLifeDateField (birth/death) and RelationshipDateField (from/to).
|
||||||
|
*
|
||||||
|
* Distinct from {@link DatePrecisionField} — that one is the full document/timeline
|
||||||
|
* field (all seven precisions, German free-text entry, RANGE end-date disclosure).
|
||||||
|
* This one is the restricted, single-input variant for the person-family forms.
|
||||||
|
*
|
||||||
|
* All copy (legend, precision labels, hint, the select's accessible name) and the
|
||||||
|
* offered precisions are injected by the caller so this stays domain-agnostic.
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
legend,
|
||||||
|
precisionLabel,
|
||||||
|
hint,
|
||||||
|
precisions,
|
||||||
|
initialIso = '',
|
||||||
|
initialPrecision = null,
|
||||||
|
inputClass = '',
|
||||||
|
selectClass = ''
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
legend: string;
|
||||||
|
precisionLabel: string;
|
||||||
|
hint: string;
|
||||||
|
precisions: { value: DatePrecision; label: string }[];
|
||||||
|
initialIso?: string | null;
|
||||||
|
initialPrecision?: string | null;
|
||||||
|
inputClass?: string;
|
||||||
|
selectClass?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let iso = $state('');
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
let inputEl = $state<HTMLInputElement | undefined>();
|
||||||
|
let precision = $state<DatePrecision>('DAY');
|
||||||
|
|
||||||
|
// Seed once at mount so a later load() rerun does not stomp an in-progress edit.
|
||||||
|
onMount(() => {
|
||||||
|
if (initialIso) {
|
||||||
|
iso = initialIso;
|
||||||
|
}
|
||||||
|
const offered = precisions.some((p) => p.value === initialPrecision);
|
||||||
|
if (offered) {
|
||||||
|
precision = initialPrecision as DatePrecision;
|
||||||
|
} else if (initialIso) {
|
||||||
|
// A stored non-offered precision (SEASON/RANGE/APPROX) seeds as 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 — block native submission until the
|
||||||
|
// date is completed or fully emptied, so a save can never silently clear a date.
|
||||||
|
$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>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
id={name}
|
||||||
|
placeholder="TT.MM.JJJJ"
|
||||||
|
ariaLabel={legend}
|
||||||
|
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
|
||||||
|
class="{controlCls} {inputClass}"
|
||||||
|
/>
|
||||||
|
{#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} {selectClass}"
|
||||||
|
>
|
||||||
|
{#each 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">{hint}</p>
|
||||||
|
</fieldset>
|
||||||
@@ -66,6 +66,27 @@ export function formatDocumentDate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats one nullable date at the precision the data claims, delegating all
|
||||||
|
* rendering to {@link formatDocumentDate}. Returns '' for a missing date; a
|
||||||
|
* missing precision falls back to YEAR — pre-precision rows knew only a year,
|
||||||
|
* and a bare year is the only safe rendering without precision metadata.
|
||||||
|
*
|
||||||
|
* This is the shared core of {@link formatLifeDate} (person birth/death) and the
|
||||||
|
* relationship from/to formatter. Range-level glyphs and dashes belong in those
|
||||||
|
* domain wrappers, never here.
|
||||||
|
*/
|
||||||
|
export function formatDatePart(
|
||||||
|
date: string | null | undefined,
|
||||||
|
precision: DatePrecision | null | undefined,
|
||||||
|
locale?: string
|
||||||
|
): string {
|
||||||
|
if (!date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── precision branches ──────────────────────────────────────────────────────
|
// ─── precision branches ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function longDate(iso: string, locale: string): string {
|
function longDate(iso: string, locale: string): string {
|
||||||
|
|||||||
64
frontend/src/lib/timeline/BucketHeaderChip.svelte
Normal file
64
frontend/src/lib/timeline/BucketHeaderChip.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header chip of a Thema-mode root-tag bucket (#827, REQ-015): a *fully-tinted* chip whose
|
||||||
|
* fill and label both derive from the root tag's `--c-tag-*` colour token — distinct from the
|
||||||
|
* neutral per-letter {@link TagChip} (a surface pill with a tiny colour square). The label uses
|
||||||
|
* the saturated token as text over a subtle `color-mix` wash of the same token, so the ≥4.5:1
|
||||||
|
* label contrast holds in both light and dark themes. A `null` colour — or any value outside the
|
||||||
|
* known token set (the §2 `krieg`/`weih`/`fam` are demo class names, not tokens) — falls back to a
|
||||||
|
* neutral chip with no `var(--c-tag-)` reference, never a broken colour. The name is
|
||||||
|
* curator/import-derived and rendered through default `{...}` escaping, never the raw-HTML
|
||||||
|
* directive (REQ-009).
|
||||||
|
*/
|
||||||
|
const TAG_COLORS = new Set([
|
||||||
|
'sage',
|
||||||
|
'sienna',
|
||||||
|
'amber',
|
||||||
|
'slate',
|
||||||
|
'violet',
|
||||||
|
'rose',
|
||||||
|
'cobalt',
|
||||||
|
'moss',
|
||||||
|
'sand',
|
||||||
|
'coral'
|
||||||
|
]);
|
||||||
|
|
||||||
|
let { name, color }: { name: string; color: string | null } = $props();
|
||||||
|
|
||||||
|
const token = $derived(color && TAG_COLORS.has(color) ? color : null);
|
||||||
|
// The tint paints the chip's fill + dot only — never the label text. The saturated
|
||||||
|
// --c-tag-* tokens used AS text over their own wash drop below WCAG AA 4.5:1 for the
|
||||||
|
// light tokens (amber ≈3.0, sand ≈3.2, sage ≈3.4); a fixed dark ink keeps every token
|
||||||
|
// legible while the 18% wash still reads as a genuinely tinted chip (REQ-015).
|
||||||
|
const chipStyle = $derived(
|
||||||
|
token ? `background-color: color-mix(in srgb, var(--c-tag-${token}) 18%, transparent)` : ''
|
||||||
|
);
|
||||||
|
const dotStyle = $derived(token ? `background-color: var(--c-tag-${token})` : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid="bucket-header-chip"
|
||||||
|
title={name}
|
||||||
|
style={chipStyle}
|
||||||
|
class="inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-0.5 font-sans text-xs font-semibold"
|
||||||
|
class:border={!token}
|
||||||
|
class:border-line={!token}
|
||||||
|
class:bg-surface={!token}
|
||||||
|
>
|
||||||
|
<span class="sr-only">{m.timeline_tag_chip_label()}: </span>
|
||||||
|
<span
|
||||||
|
data-testid="bucket-header-chip-dot"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={dotStyle}
|
||||||
|
class="inline-block h-2 w-2 flex-shrink-0 rounded-sm"
|
||||||
|
class:bg-ink-3={!token}
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
data-testid="bucket-header-chip-label"
|
||||||
|
style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0"
|
||||||
|
class:text-ink={token}
|
||||||
|
class:text-ink-3={!token}>{name}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
57
frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts
Normal file
57
frontend/src/lib/timeline/BucketHeaderChip.svelte.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('BucketHeaderChip (REQ-015/009)', () => {
|
||||||
|
it('renders the root-tag name', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
|
||||||
|
expect(document.body.textContent).toContain('Krieg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tints the chip with var(--c-tag-{token}) for a known colour token (REQ-015)', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
|
||||||
|
const chip = document.querySelector('[data-testid="bucket-header-chip"]') as HTMLElement;
|
||||||
|
expect(chip.getAttribute('style')).toContain('var(--c-tag-sienna)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a neutral chip with no --c-tag- binding when colour is null (REQ-015)', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Ohne Thema', color: null });
|
||||||
|
expect(document.body.textContent).toContain('Ohne Thema');
|
||||||
|
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to neutral for an unknown colour token, never a broken var (REQ-015)', () => {
|
||||||
|
// "krieg" is a §2 demo class name, not a real --c-tag-* token.
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'krieg' });
|
||||||
|
expect(document.body.innerHTML).not.toContain('var(--c-tag-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefixes the name with an sr-only theme label so colour is never the only cue', () => {
|
||||||
|
render(BucketHeaderChip, { name: 'Krieg', color: 'sienna' });
|
||||||
|
const srOnly = document.querySelector('.sr-only');
|
||||||
|
expect(srOnly?.textContent).toContain(m.timeline_tag_chip_label());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an HTML-bearing name as inert text, never markup (REQ-009)', () => {
|
||||||
|
const evil = '<img src=x onerror="alert(1)">';
|
||||||
|
render(BucketHeaderChip, { name: evil, color: null });
|
||||||
|
expect(document.body.textContent).toContain(evil);
|
||||||
|
expect(document.querySelector('img')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints the label in a fixed ink colour, never the saturated tag token (contrast, REQ-015)', () => {
|
||||||
|
// A saturated --c-tag-* token used as TEXT over its own wash fails 4.5:1 for the
|
||||||
|
// light tokens (amber/sand/sage ≈ 3:1). The tint must go to the background + dot;
|
||||||
|
// the label keeps a guaranteed-contrast ink token.
|
||||||
|
render(BucketHeaderChip, { name: 'Weihnachten', color: 'amber' });
|
||||||
|
const chip = document.querySelector('[data-testid="bucket-header-chip"]') as HTMLElement;
|
||||||
|
expect(chip.getAttribute('style') ?? '').not.toContain('color: var(--c-tag-');
|
||||||
|
const label = document.querySelector('[data-testid="bucket-header-chip-label"]') as HTMLElement;
|
||||||
|
expect(label.className).toContain('text-ink');
|
||||||
|
// still genuinely tinted — the token paints the wash and the dot
|
||||||
|
expect(document.body.innerHTML).toContain('var(--c-tag-amber)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,9 +10,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|||||||
* Centered axis pill for a derived life-event or a curated PERSONAL event
|
* Centered axis pill for a derived life-event or a curated PERSONAL event
|
||||||
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
|
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
|
||||||
* (REQ-018). An edit affordance shows only for a curated event with an eventId
|
* (REQ-018). An edit affordance shows only for a curated event with an eventId
|
||||||
* (never derived, never null — REQ-008).
|
* (never derived, never null — REQ-008) and only for a curator who holds
|
||||||
|
* WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The
|
||||||
|
* gate is UX only; the real boundary is the #781 route guard + backend permission.
|
||||||
*/
|
*/
|
||||||
let { entry }: { entry: TimelineEntryDTO } = $props();
|
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
|
||||||
|
|
||||||
const config = $derived(getAccentConfig(entry));
|
const config = $derived(getAccentConfig(entry));
|
||||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||||
@@ -24,7 +26,7 @@ const provenance = $derived(
|
|||||||
// Provenance always shows; the date is an optional prefix so an undated event
|
// Provenance always shows; the date is an optional prefix so an undated event
|
||||||
// still reads "abgeleitet"/"kuratiert" (REQ-007).
|
// still reads "abgeleitet"/"kuratiert" (REQ-007).
|
||||||
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
|
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
|
||||||
const canEdit = $derived(!entry.derived && entry.eventId != null);
|
const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ describe('EventPill', () => {
|
|||||||
expect(srOnly?.textContent).toBe('Geburt');
|
expect(srOnly?.textContent).toBe('Geburt');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
|
it('shows an edit affordance for a curated PERSONAL event when canWrite is true (REQ-005)', () => {
|
||||||
render(EventPill, {
|
render(EventPill, {
|
||||||
|
canWrite: true,
|
||||||
entry: makeEntry({
|
entry: makeEntry({
|
||||||
kind: 'EVENT',
|
kind: 'EVENT',
|
||||||
derived: false,
|
derived: false,
|
||||||
@@ -66,11 +67,45 @@ describe('EventPill', () => {
|
|||||||
});
|
});
|
||||||
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
|
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
|
||||||
expect(edit).not.toBeNull();
|
expect(edit).not.toBeNull();
|
||||||
expect(edit?.getAttribute('href')).toContain(EVENT_ID);
|
expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${EVENT_ID}/edit`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no edit affordance when eventId is null (REQ-008)', () => {
|
it('renders no edit affordance for a curated PERSONAL event when canWrite is false (REQ-007)', () => {
|
||||||
render(EventPill, {
|
render(EventPill, {
|
||||||
|
canWrite: false,
|
||||||
|
entry: makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
derived: false,
|
||||||
|
type: 'PERSONAL',
|
||||||
|
eventId: EVENT_ID,
|
||||||
|
title: 'Auswanderung',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no edit affordance when the canWrite prop is omitted (gate-closed default) (REQ-007)', () => {
|
||||||
|
render(EventPill, {
|
||||||
|
entry: makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
derived: false,
|
||||||
|
type: 'PERSONAL',
|
||||||
|
eventId: EVENT_ID,
|
||||||
|
title: 'Auswanderung',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => {
|
||||||
|
render(EventPill, {
|
||||||
|
canWrite: true,
|
||||||
entry: makeEntry({
|
entry: makeEntry({
|
||||||
kind: 'EVENT',
|
kind: 'EVENT',
|
||||||
derived: false,
|
derived: false,
|
||||||
@@ -85,8 +120,8 @@ describe('EventPill', () => {
|
|||||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no edit affordance for a derived event (REQ-008)', () => {
|
it('shows no edit affordance for a derived event even with canWrite (REQ-008)', () => {
|
||||||
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
|
render(EventPill, { canWrite: true, entry: derived('MARRIAGE', 'Heirat') });
|
||||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
114
frontend/src/lib/timeline/GroupingControl.svelte
Normal file
114
frontend/src/lib/timeline/GroupingControl.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { GroupingMode } from './timelineGrouping';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Datum·Ereignis·Thema segmented control (#827, REQ-010/011/018). An ARIA radiogroup with
|
||||||
|
* roving tabindex — single selection, arrow-key navigable — deliberately distinct from #780's
|
||||||
|
* `aria-pressed` layer-filter toggles. Defaults to Datum. Each segment is ≥44×44px, carries a
|
||||||
|
* text label (full word as `aria-label`, an abbreviated label shown ≤360px so the control never
|
||||||
|
* overflows at 320px), and uses semantic tokens so the selected/unselected contrast holds in dark
|
||||||
|
* mode. When `disabled` (the Letters layer is off, nothing to regroup) the control stays in place
|
||||||
|
* — no reflow — keeps its `aria-checked` selection so re-enabling restores the mode, and announces
|
||||||
|
* a screen-reader reason.
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
mode = $bindable('date'),
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel = m.timeline_grouping_aria_label()
|
||||||
|
}: { mode?: GroupingMode; disabled?: boolean; ariaLabel?: string } = $props();
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
value: GroupingMode;
|
||||||
|
full: string;
|
||||||
|
short: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: Segment[] = [
|
||||||
|
{
|
||||||
|
value: 'date',
|
||||||
|
full: m.timeline_grouping_segment_date(),
|
||||||
|
short: m.timeline_grouping_segment_date_short()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'event',
|
||||||
|
full: m.timeline_grouping_segment_event(),
|
||||||
|
short: m.timeline_grouping_segment_event_short()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'thema',
|
||||||
|
full: m.timeline_grouping_segment_thema(),
|
||||||
|
short: m.timeline_grouping_segment_thema_short()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function select(value: GroupingMode) {
|
||||||
|
if (disabled) return;
|
||||||
|
mode = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
|
||||||
|
const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
|
||||||
|
if (!forward && !backward) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const index = segments.findIndex((s) => s.value === mode);
|
||||||
|
const delta = forward ? 1 : -1;
|
||||||
|
const next = segments[(index + delta + segments.length) % segments.length];
|
||||||
|
mode = next.value;
|
||||||
|
const groupEl = event.currentTarget as HTMLElement;
|
||||||
|
groupEl.querySelector<HTMLElement>(`[data-value="${next.value}"]`)?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
data-testid="grouping-control"
|
||||||
|
class="inline-flex overflow-hidden rounded-md border border-line"
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
>
|
||||||
|
{#each segments as segment (segment.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
data-value={segment.value}
|
||||||
|
aria-label={segment.full}
|
||||||
|
aria-checked={mode === segment.value}
|
||||||
|
tabindex={mode === segment.value ? 0 : -1}
|
||||||
|
disabled={disabled}
|
||||||
|
onclick={() => select(segment.value)}
|
||||||
|
style="display: inline-flex; align-items: center; justify-content: center; min-height: 44px; min-width: 44px"
|
||||||
|
class="px-3 py-2 font-sans text-xs font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
||||||
|
class:bg-brand-navy={mode === segment.value && !disabled}
|
||||||
|
class:text-white={mode === segment.value && !disabled}
|
||||||
|
class:bg-surface={mode !== segment.value || disabled}
|
||||||
|
class:text-ink-3={mode !== segment.value || disabled}
|
||||||
|
>
|
||||||
|
<span class="seg-full">{segment.full}</span>
|
||||||
|
<span class="seg-short">{segment.short}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if disabled}
|
||||||
|
<span class="sr-only" role="status" data-testid="grouping-disabled-reason"
|
||||||
|
>{m.timeline_grouping_disabled_reason()}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.seg-short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.seg-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.seg-short {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
frontend/src/lib/timeline/GroupingControl.svelte.spec.ts
Normal file
106
frontend/src/lib/timeline/GroupingControl.svelte.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import GroupingControl from './GroupingControl.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const radios = () => Array.from(document.querySelectorAll('[role="radio"]')) as HTMLElement[];
|
||||||
|
const group = () => document.querySelector('[role="radiogroup"]') as HTMLElement;
|
||||||
|
const checkedValue = () =>
|
||||||
|
radios()
|
||||||
|
.find((r) => r.getAttribute('aria-checked') === 'true')
|
||||||
|
?.getAttribute('data-value');
|
||||||
|
|
||||||
|
describe('GroupingControl (REQ-010)', () => {
|
||||||
|
it('renders three radios inside a radiogroup, each with aria-checked (a)', () => {
|
||||||
|
render(GroupingControl, {});
|
||||||
|
expect(group()).not.toBeNull();
|
||||||
|
const r = radios();
|
||||||
|
expect(r).toHaveLength(3);
|
||||||
|
r.forEach((radio) => expect(radio.hasAttribute('aria-checked')).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to Datum (f)', () => {
|
||||||
|
render(GroupingControl, {});
|
||||||
|
expect(radios().filter((r) => r.getAttribute('aria-checked') === 'true')).toHaveLength(1);
|
||||||
|
expect(checkedValue()).toBe('date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a text label on every segment, not colour alone (d)', () => {
|
||||||
|
render(GroupingControl, {});
|
||||||
|
radios().forEach((r) => expect((r.textContent ?? '').trim().length).toBeGreaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the radiogroup an accessible name (e)', () => {
|
||||||
|
render(GroupingControl, {});
|
||||||
|
expect(group().getAttribute('aria-label')).toBe(m.timeline_grouping_aria_label());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each segment has a tap target of at least 44×44px (c)', () => {
|
||||||
|
render(GroupingControl, {});
|
||||||
|
radios().forEach((r) => {
|
||||||
|
const rect = r.getBoundingClientRect();
|
||||||
|
expect(rect.width).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(rect.height).toBeGreaterThanOrEqual(44);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes each segment full word as an aria-label (REQ-011)', () => {
|
||||||
|
render(GroupingControl, {});
|
||||||
|
const labels = radios().map((r) => r.getAttribute('aria-label'));
|
||||||
|
expect(labels).toEqual([
|
||||||
|
m.timeline_grouping_segment_date(),
|
||||||
|
m.timeline_grouping_segment_event(),
|
||||||
|
m.timeline_grouping_segment_thema()
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the selection forward with the right arrow key (b)', async () => {
|
||||||
|
render(GroupingControl, { mode: 'date' });
|
||||||
|
group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
expect(checkedValue()).toBe('event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps to the last segment with the left arrow from Datum (b)', async () => {
|
||||||
|
render(GroupingControl, { mode: 'date' });
|
||||||
|
group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
expect(checkedValue()).toBe('thema');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a segment on click', async () => {
|
||||||
|
render(GroupingControl, { mode: 'date' });
|
||||||
|
const thema = radios().find((r) => r.getAttribute('data-value') === 'thema')!;
|
||||||
|
thema.click();
|
||||||
|
await tick();
|
||||||
|
expect(thema.getAttribute('aria-checked')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GroupingControl — disabled (REQ-018)', () => {
|
||||||
|
it('marks the radiogroup aria-disabled and keeps all radios in the DOM', () => {
|
||||||
|
render(GroupingControl, { mode: 'event', disabled: true });
|
||||||
|
expect(group().getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(radios()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a screen-reader reason that letters are hidden', () => {
|
||||||
|
render(GroupingControl, { disabled: true });
|
||||||
|
const reason = document.querySelector('[data-testid="grouping-disabled-reason"]');
|
||||||
|
expect(reason?.textContent).toContain(m.timeline_grouping_disabled_reason());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the active mode while disabled (no reset to Datum)', () => {
|
||||||
|
render(GroupingControl, { mode: 'thema', disabled: true });
|
||||||
|
expect(checkedValue()).toBe('thema');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores arrow keys while disabled', () => {
|
||||||
|
render(GroupingControl, { mode: 'event', disabled: true });
|
||||||
|
group().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(checkedValue()).toBe('event');
|
||||||
|
});
|
||||||
|
});
|
||||||
195
frontend/src/lib/timeline/LetterBucket.svelte
Normal file
195
frontend/src/lib/timeline/LetterBucket.svelte
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import LetterCard from './LetterCard.svelte';
|
||||||
|
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||||
|
import { entryKey } from './entryKey';
|
||||||
|
import { getAccentConfig } from './eventCardConfig';
|
||||||
|
import { timelineDateLabel } from './dateLabel';
|
||||||
|
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
|
||||||
|
* unit (#827). The axis-fixed world-band layer is rendered elsewhere — this is only the
|
||||||
|
* loose-letter bundling.
|
||||||
|
*
|
||||||
|
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
|
||||||
|
* tag chip is suppressed (REQ-004/015/017).
|
||||||
|
* - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date,
|
||||||
|
* provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign,
|
||||||
|
* REQ-001/014). A cross-year cluster keeps a plain text header. The standalone "Weitere Briefe"
|
||||||
|
* / "Ohne Thema" fallback keeps its label and a neutral dashed rail (REQ-006/007).
|
||||||
|
*
|
||||||
|
* A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest
|
||||||
|
* instead of flooding the timeline with every card (#827 redesign).
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
bucket,
|
||||||
|
mode,
|
||||||
|
// `year` is the band's year — accepted for the cross-year label card seam (#827) but no
|
||||||
|
// longer consumed here now the in-bucket month-density strip is gone (the year frames the
|
||||||
|
// time from the band heading). Kept in the prop contract for callers/tests.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
year = 0,
|
||||||
|
nested = false,
|
||||||
|
event = undefined,
|
||||||
|
canWrite = false
|
||||||
|
}: {
|
||||||
|
bucket: LetterBucket;
|
||||||
|
mode: 'event' | 'thema';
|
||||||
|
year?: number;
|
||||||
|
nested?: boolean;
|
||||||
|
/** The same-year curated event whose letters this card holds — renders as the header. */
|
||||||
|
event?: TimelineEntryDTO;
|
||||||
|
canWrite?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const count = $derived(bucket.letters.length);
|
||||||
|
const fallbackLabel = $derived(
|
||||||
|
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Event-as-header (#827 redesign): a same-year curated event renders as this card's header,
|
||||||
|
// mirroring EventPill — glyph + title + date · provenance + an edit pencil for a curator. The
|
||||||
|
// title is never repeated as a separate floating pill.
|
||||||
|
const accent = $derived(event ? getAccentConfig(event) : null);
|
||||||
|
const eventDateLabel = $derived(
|
||||||
|
event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null
|
||||||
|
);
|
||||||
|
const provenance = $derived(
|
||||||
|
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
||||||
|
);
|
||||||
|
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
|
||||||
|
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
|
||||||
|
// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster,
|
||||||
|
// neutral for the fallback (and for a colourless/unknown tag token).
|
||||||
|
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
|
||||||
|
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
|
||||||
|
const isEventCluster = $derived(nested || bucket.kind === 'event');
|
||||||
|
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
|
||||||
|
|
||||||
|
// First-5 preview + show-more (#827 redesign): a large cluster stays readable instead of
|
||||||
|
// dumping every card into the timeline.
|
||||||
|
let expanded = $state(false);
|
||||||
|
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
|
||||||
|
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
|
||||||
|
|
||||||
|
// The catch-all "Weitere Briefe" / "Ohne Thema" bin is a junk drawer: render it count-only
|
||||||
|
// behind a reveal control so it never floods the timeline; every other cluster starts open
|
||||||
|
// (#827 redesign). The view re-creates a bucket per `{#each}` key, so the initial capture is
|
||||||
|
// the right lifetime — `revealed` belongs to this bucket instance.
|
||||||
|
const isDrawer = $derived(bucket.kind === 'fallback');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let revealed = $state(bucket.kind !== 'fallback');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="my-3 overflow-hidden rounded-md border border-l-2 border-line bg-surface shadow-sm"
|
||||||
|
class:border-l-brand-mint={isEventCluster}
|
||||||
|
class:border-dashed={isDrawer}
|
||||||
|
style={railStyle}
|
||||||
|
data-testid="letter-bucket"
|
||||||
|
data-bucket-kind={bucket.kind}
|
||||||
|
>
|
||||||
|
{#if !nested}
|
||||||
|
{#if event && accent}
|
||||||
|
<!-- A same-year curated event IS the card header — its title reads once here, never
|
||||||
|
also as a floating pill (#827 redesign, REQ-001/014). Glyph is aria-hidden with an
|
||||||
|
sr-only label sibling (REQ-018); the edit pencil mirrors EventPill's gate. -->
|
||||||
|
<header
|
||||||
|
data-testid="bucket-event-header"
|
||||||
|
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {accent.accent ===
|
||||||
|
'curated'
|
||||||
|
? 'bg-brand-mint text-brand-navy'
|
||||||
|
: 'bg-brand-navy text-brand-mint'}"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{accent.glyph}</span>
|
||||||
|
<span class="sr-only">{accent.label}</span>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 text-left">
|
||||||
|
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
|
||||||
|
>{event.title}</span
|
||||||
|
>
|
||||||
|
<span class="block font-sans text-xs text-ink-3">
|
||||||
|
{eventSubtitle} <span data-testid="bucket-count">· {count}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<a
|
||||||
|
data-testid="event-edit"
|
||||||
|
href="/zeitstrahl/events/{event.eventId}/edit"
|
||||||
|
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✎</span>
|
||||||
|
<span class="sr-only">{m.btn_edit()}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
{:else}
|
||||||
|
<header
|
||||||
|
class="flex items-center gap-2 px-3 py-2"
|
||||||
|
class:bg-canvas={isEventCluster}
|
||||||
|
class:border-b={!isDrawer}
|
||||||
|
class:border-line={!isDrawer}
|
||||||
|
>
|
||||||
|
{#if mode === 'thema' && bucket.kind === 'tag'}
|
||||||
|
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
|
||||||
|
{:else if mode === 'event' && bucket.kind === 'event'}
|
||||||
|
<span class="font-serif text-sm font-bold text-ink">
|
||||||
|
<span aria-hidden="true">✉</span>
|
||||||
|
{bucket.title}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
|
||||||
|
{/if}
|
||||||
|
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
{#if !revealed}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="bucket-reveal"
|
||||||
|
onclick={() => (revealed = true)}
|
||||||
|
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||||
|
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1.5">
|
||||||
|
{#each visible as letter (entryKey(letter))}
|
||||||
|
<li>
|
||||||
|
<LetterCard
|
||||||
|
entry={letter}
|
||||||
|
variant={cardVariant}
|
||||||
|
suppressTagChip={mode === 'thema'}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if hiddenCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="bucket-show-more"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||||
|
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? m.timeline_bucket_show_less()
|
||||||
|
: m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
232
frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
Normal file
232
frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import LetterBucket from './LetterBucket.svelte';
|
||||||
|
import { makeEntry } from './test-factories';
|
||||||
|
import type { LetterBucket as Bucket } from './timelineGrouping';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const eventBucket: Bucket = {
|
||||||
|
key: 'event:e1',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Briefe von der Front',
|
||||||
|
color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'a' }), makeEntry({ documentId: 'b' })]
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagBucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Krieg',
|
||||||
|
color: 'sienna',
|
||||||
|
letters: [makeEntry({ documentId: 'c', rootTagName: 'Krieg', rootTagColor: 'sienna' })]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LetterBucket — Ereignis mode (REQ-003/006/014)', () => {
|
||||||
|
it('shows the event title and the cluster count', () => {
|
||||||
|
render(LetterBucket, { bucket: eventBucket, mode: 'event' });
|
||||||
|
expect(document.body.textContent).toContain('Briefe von der Front');
|
||||||
|
expect(document.querySelector('[data-testid="bucket-count"]')?.textContent).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders its letters as .lcard.ev event cards (REQ-014)', () => {
|
||||||
|
render(LetterBucket, { bucket: eventBucket, mode: 'event' });
|
||||||
|
expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the localized "Weitere Briefe" label and plain cards for the fallback bucket (REQ-006)', () => {
|
||||||
|
const fb: Bucket = {
|
||||||
|
key: '__fallback__',
|
||||||
|
kind: 'fallback',
|
||||||
|
color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'x' })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket: fb, mode: 'event' });
|
||||||
|
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
|
||||||
|
// fallback letters are not clustered under a curated event → plain card, never .lcard.ev
|
||||||
|
expect(document.querySelector('a.ev')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => {
|
||||||
|
it('renders a tinted bucket-header chip carrying the root-tag name (REQ-015)', () => {
|
||||||
|
render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
|
||||||
|
const chip = document.querySelector('[data-testid="bucket-header-chip"]');
|
||||||
|
expect(chip?.textContent).toContain('Krieg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses the per-letter tag chip inside its own root-tag bucket (REQ-017)', () => {
|
||||||
|
render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
|
||||||
|
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the localized "Ohne Thema" label for the untagged fallback bucket (REQ-007)', () => {
|
||||||
|
const fb: Bucket = {
|
||||||
|
key: '__fallback__',
|
||||||
|
kind: 'fallback',
|
||||||
|
color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'y', rootTagName: undefined })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket: fb, mode: 'thema' });
|
||||||
|
expect(document.body.textContent).toContain(m.timeline_bucket_no_topic());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const manyLetters = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) =>
|
||||||
|
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('LetterBucket — preview cap + show-more (#827 redesign)', () => {
|
||||||
|
it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Krieg',
|
||||||
|
color: 'sienna',
|
||||||
|
letters: manyLetters(8)
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands to all letters and collapses back on toggle', async () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Krieg',
|
||||||
|
color: 'sienna',
|
||||||
|
letters: manyLetters(8)
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(8);
|
||||||
|
(document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all letters and no toggle for a small cluster (<= 5)', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Tod',
|
||||||
|
color: null,
|
||||||
|
letters: manyLetters(3)
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
|
||||||
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds a tag bucket together with a coloured left rail from its token', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Krieg',
|
||||||
|
color: 'sienna',
|
||||||
|
letters: manyLetters(1)
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
const section = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||||
|
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
|
||||||
|
const fb = (n: number): Bucket => ({
|
||||||
|
key: '__fallback__',
|
||||||
|
kind: 'fallback',
|
||||||
|
color: null,
|
||||||
|
letters: Array.from({ length: n }, (_, i) =>
|
||||||
|
makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` })
|
||||||
|
)
|
||||||
|
});
|
||||||
|
it('renders collapsed — count + reveal, no letter cards — until opened', () => {
|
||||||
|
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||||
|
expect(document.querySelector('a.lcard')).toBeNull();
|
||||||
|
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
|
||||||
|
expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
it('reveals the first 5 letters when opened', async () => {
|
||||||
|
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||||
|
(document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LetterBucket — card chrome (#827 redesign)', () => {
|
||||||
|
it('renders the cluster as a contained card (bordered, rounded, surface)', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Krieg',
|
||||||
|
color: 'sienna',
|
||||||
|
letters: [makeEntry({ documentId: 'a' })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||||
|
expect(card.className).toMatch(/\brounded\b|rounded-/);
|
||||||
|
expect(card.className).toContain('border');
|
||||||
|
expect(card.className).toContain('bg-surface');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LetterBucket — event-as-header (#827 redesign)', () => {
|
||||||
|
it('renders the curated event as the card header when given an `event` (no separate pill)', () => {
|
||||||
|
const event = makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'e1',
|
||||||
|
title: 'Ein gewaltiger Stadtbrand',
|
||||||
|
eventDate: '1916-07-06',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'event:e1',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Ein gewaltiger Stadtbrand',
|
||||||
|
color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true });
|
||||||
|
const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement;
|
||||||
|
expect(header.textContent).toContain('Ein gewaltiger Stadtbrand');
|
||||||
|
expect(header.textContent).toContain(m.timeline_provenance_curated());
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe(
|
||||||
|
'/zeitstrahl/events/e1/edit'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no edit affordance in the header when canWrite is false', () => {
|
||||||
|
const event = makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'e1',
|
||||||
|
title: 'X',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'event:e1',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'X',
|
||||||
|
color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'a' })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false });
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,10 +12,30 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|||||||
* precision-aware date chip, linking to the document. Names/titles are
|
* precision-aware date chip, linking to the document. Names/titles are
|
||||||
* OCR/import-derived — rendered via default `{...}` escaping with
|
* OCR/import-derived — rendered via default `{...}` escaping with
|
||||||
* `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
|
* `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
|
||||||
|
*
|
||||||
|
* In Ereignis mode the card sits inside an event cluster and renders as the
|
||||||
|
* `.lcard.ev` variant (#827, REQ-014). In Thema mode the per-letter tag chip is
|
||||||
|
* suppressed inside its own root-tag bucket, where the bucket header already
|
||||||
|
* carries the topic (`suppressTagChip`, REQ-017).
|
||||||
*/
|
*/
|
||||||
let { entry }: { entry: TimelineEntryDTO } = $props();
|
let {
|
||||||
|
entry,
|
||||||
|
variant = 'plain',
|
||||||
|
suppressTagChip = false,
|
||||||
|
compact = false
|
||||||
|
}: {
|
||||||
|
entry: TimelineEntryDTO;
|
||||||
|
variant?: 'plain' | 'event';
|
||||||
|
suppressTagChip?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isEventVariant = $derived(variant === 'event');
|
||||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||||
|
// Inside a per-year bucket the year frames the time, and these archive titles already
|
||||||
|
// embed the date — so the compact in-bucket card drops the redundant date chip when a
|
||||||
|
// title is present, halving the row height and killing the duplicate date (#827).
|
||||||
|
const showDate = $derived(!compact || !entry.title);
|
||||||
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
|
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
|
||||||
const receiver = $derived(
|
const receiver = $derived(
|
||||||
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
|
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
|
||||||
@@ -28,28 +48,37 @@ const receiver = $derived(
|
|||||||
<a
|
<a
|
||||||
href="/documents/{entry.documentId}"
|
href="/documents/{entry.documentId}"
|
||||||
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
|
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
|
||||||
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
class="lcard rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
class:py-2={!compact}
|
||||||
|
class:py-1={compact}
|
||||||
|
class:ev={isEventVariant}
|
||||||
|
class:compact={compact}
|
||||||
>
|
>
|
||||||
{#if entry.title}
|
{#if entry.title}
|
||||||
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
|
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
|
||||||
interpolated into the escaped user title; the title keeps its own
|
interpolated into the escaped user title; the title keeps its own
|
||||||
pre-line span for multi-line OCR text (REQ-008/016/021). -->
|
pre-line span for multi-line OCR text (REQ-008/016/021). -->
|
||||||
<span class="font-serif text-sm font-bold break-words text-ink">
|
<span
|
||||||
|
class="font-serif font-bold break-words text-ink"
|
||||||
|
class:text-sm={!compact}
|
||||||
|
class:text-xs={compact}
|
||||||
|
>
|
||||||
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
|
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
|
||||||
<span class="whitespace-pre-line">{entry.title}</span>
|
<span class="whitespace-pre-line">{entry.title}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
|
<span class="font-sans text-xs break-words text-ink-3" class:mt-0.5={!compact}>
|
||||||
<span class="font-serif whitespace-pre-line">{sender}</span>
|
<span class="font-serif whitespace-pre-line">{sender}</span>
|
||||||
<span aria-hidden="true">→</span>
|
<span aria-hidden="true">→</span>
|
||||||
<span class="font-serif whitespace-pre-line">{receiver}</span>
|
<span class="font-serif whitespace-pre-line">{receiver}</span>
|
||||||
{#if dateLabel}
|
{#if dateLabel && showDate}
|
||||||
<span data-testid="letter-date"> · {dateLabel}</span>
|
<span data-testid="letter-date"> · {dateLabel}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if entry.rootTagName}
|
{#if entry.rootTagName && !suppressTagChip}
|
||||||
<!-- The primary root-tag chip sits on its own line beneath the meta line
|
<!-- The primary root-tag chip sits on its own line beneath the meta line
|
||||||
(#835 §3); absent when the letter has no tag (REQ-005). -->
|
(#835 §3); absent when the letter has no tag (REQ-005), and suppressed in
|
||||||
|
Thema mode inside its own root-tag bucket where the header conveys it (REQ-017). -->
|
||||||
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
|
<TagChip name={entry.rootTagName} color={entry.rootTagColor ?? null} />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -127,3 +127,46 @@ describe('LetterCard', () => {
|
|||||||
expect(chip?.textContent).toContain('Familie');
|
expect(chip?.textContent).toContain('Familie');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LetterCard — grouping variants (#827, REQ-014/017)', () => {
|
||||||
|
it('carries the .lcard.ev class in the event variant (REQ-014)', () => {
|
||||||
|
render(LetterCard, { entry: makeEntry(), variant: 'event' });
|
||||||
|
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a plain card with no .ev marker by default (REQ-014)', () => {
|
||||||
|
render(LetterCard, { entry: makeEntry() });
|
||||||
|
expect(document.querySelector('a.ev')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses the per-letter tag chip when asked, even with a root tag (REQ-017)', () => {
|
||||||
|
render(LetterCard, {
|
||||||
|
entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }),
|
||||||
|
suppressTagChip: true
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still shows the per-letter tag chip when not suppressed — Datum/Ereignis (REQ-017)', () => {
|
||||||
|
render(LetterCard, { entry: makeEntry({ rootTagName: 'Krieg', rootTagColor: 'sienna' }) });
|
||||||
|
expect(document.querySelector('[data-testid="tag-chip"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the redundant date line in the compact variant when a title is present (#827)', () => {
|
||||||
|
// Inside a per-year bucket the year already frames the time, and these archive
|
||||||
|
// titles embed the date — so the compact in-bucket card omits the date chip.
|
||||||
|
render(LetterCard, { entry: makeEntry({ title: 'H-0023 – 6. Juli 1916' }), compact: true });
|
||||||
|
expect(document.querySelector('[data-testid="letter-date"]')).toBeNull();
|
||||||
|
expect(document.body.textContent).toContain('Karl Raddatz'); // sender still shown
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the date in the compact variant when the letter has no title (#827)', () => {
|
||||||
|
render(LetterCard, { entry: makeEntry({ title: undefined }), compact: true });
|
||||||
|
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the compact variant on a single tighter row (#827)', () => {
|
||||||
|
render(LetterCard, { entry: makeEntry(), compact: true });
|
||||||
|
expect(document.querySelector('a.lcard.compact')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
133
frontend/src/lib/timeline/TimelineFilters.svelte
Normal file
133
frontend/src/lib/timeline/TimelineFilters.svelte
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { hiddenLayerCount, isDefaultState, type TimelineLayerFilters } from './timelineFilter';
|
||||||
|
|
||||||
|
// Presentation-only layer filter for the global /zeitstrahl (#780, REQ-001).
|
||||||
|
// Holds no timeline data and never navigates or fetches — the route owns the
|
||||||
|
// $state and derives the filtered view. Three $bindable layer booleans plus an
|
||||||
|
// onChange notification hook are the whole contract.
|
||||||
|
let {
|
||||||
|
personalOn = $bindable(true),
|
||||||
|
historicalOn = $bindable(true),
|
||||||
|
lettersOn = $bindable(true),
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
personalOn?: boolean;
|
||||||
|
historicalOn?: boolean;
|
||||||
|
lettersOn?: boolean;
|
||||||
|
onChange?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// Reuse the reduced-motion guard expression from documents/[id]/+page.svelte:57
|
||||||
|
// for a new purpose — zeroing the slide duration so the collapsible opens
|
||||||
|
// instantly when the reader prefers reduced motion (REQ-009).
|
||||||
|
const prefersReducedMotion = $derived(
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
);
|
||||||
|
const slideDuration = $derived(prefersReducedMotion ? 0 : 200);
|
||||||
|
|
||||||
|
const filters: TimelineLayerFilters = $derived({ personalOn, historicalOn, lettersOn });
|
||||||
|
const hiddenCount = $derived(hiddenLayerCount(filters));
|
||||||
|
const anyLayerOff = $derived(!isDefaultState(filters));
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
personalOn = true;
|
||||||
|
historicalOn = true;
|
||||||
|
lettersOn = true;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet layerToggle(label: string, testid: string, pressed: boolean, toggle: () => void)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={testid}
|
||||||
|
aria-pressed={pressed}
|
||||||
|
onclick={toggle}
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-2 rounded border px-3 font-sans text-sm transition-colors {pressed
|
||||||
|
? 'border-primary bg-primary text-primary-fg'
|
||||||
|
: 'border-line bg-muted text-ink-2 hover:bg-line'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="inline-flex h-4 w-4 items-center justify-center rounded-sm border {pressed
|
||||||
|
? 'border-primary-fg bg-primary-fg/20'
|
||||||
|
: 'border-ink-3'}"
|
||||||
|
>
|
||||||
|
{#if pressed}✓{/if}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<!-- Sticky trigger kept in document flow so the hidden-layer count stays
|
||||||
|
visible without clipping timeline content (REQ-007). -->
|
||||||
|
<div class="sticky top-16 z-20 bg-canvas py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="timeline-filter-trigger"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? 'timeline-filter-panel' : undefined}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-2 rounded border border-line bg-surface px-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{hiddenCount === 0
|
||||||
|
? m.timeline_filter_trigger()
|
||||||
|
: m.timeline_filter_trigger_active({ count: hiddenCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div id="timeline-filter-panel" transition:slide={{ duration: slideDuration }}>
|
||||||
|
<fieldset class="mt-2 rounded-sm border border-line bg-surface p-4">
|
||||||
|
<legend class="px-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.timeline_filter_label_layers()}
|
||||||
|
</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{@render layerToggle(
|
||||||
|
m.timeline_filter_layer_personal(),
|
||||||
|
'timeline-filter-personal',
|
||||||
|
personalOn,
|
||||||
|
() => {
|
||||||
|
personalOn = !personalOn;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{@render layerToggle(
|
||||||
|
m.timeline_filter_layer_historical(),
|
||||||
|
'timeline-filter-historical',
|
||||||
|
historicalOn,
|
||||||
|
() => {
|
||||||
|
historicalOn = !historicalOn;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{@render layerToggle(
|
||||||
|
m.timeline_filter_layer_letters(),
|
||||||
|
'timeline-filter-letters',
|
||||||
|
lettersOn,
|
||||||
|
() => {
|
||||||
|
lettersOn = !lettersOn;
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if anyLayerOff}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="timeline-filter-reset"
|
||||||
|
onclick={reset}
|
||||||
|
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{m.timeline_filter_reset()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
74
frontend/src/lib/timeline/TimelineFilters.svelte.spec.ts
Normal file
74
frontend/src/lib/timeline/TimelineFilters.svelte.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import TimelineFilters from './TimelineFilters.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const allOn = () => ({ personalOn: true, historicalOn: true, lettersOn: true, onChange: vi.fn() });
|
||||||
|
|
||||||
|
async function openBar() {
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TimelineFilters', () => {
|
||||||
|
it('renders the three layer toggles with accessible names inside a labelled group (REQ-001)', async () => {
|
||||||
|
render(TimelineFilters, allOn());
|
||||||
|
await openBar();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: m.timeline_filter_layer_personal() }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: m.timeline_filter_layer_historical() }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: m.timeline_filter_layer_letters() }))
|
||||||
|
.toBeVisible();
|
||||||
|
// the fieldset legend groups the toggles
|
||||||
|
await expect.element(page.getByText(m.timeline_filter_label_layers())).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects a layer as pressed and flips it, firing onChange (REQ-001)', async () => {
|
||||||
|
const props = allOn();
|
||||||
|
render(TimelineFilters, props);
|
||||||
|
await openBar();
|
||||||
|
const personal = page.getByTestId('timeline-filter-personal');
|
||||||
|
await expect.element(personal).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await personal.click();
|
||||||
|
await expect.element(personal).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(props.onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a plain trigger when all layers are on and a count once a layer is hidden (REQ-007/010)', async () => {
|
||||||
|
render(TimelineFilters, allOn());
|
||||||
|
const trigger = page.getByTestId('timeline-filter-trigger');
|
||||||
|
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger());
|
||||||
|
await expect.element(trigger).not.toHaveTextContent('aktiv');
|
||||||
|
await trigger.click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger_active({ count: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the trigger a 44px touch target (REQ-007)', async () => {
|
||||||
|
render(TimelineFilters, allOn());
|
||||||
|
await expect.element(page.getByTestId('timeline-filter-trigger')).toHaveClass(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the reset button by default and restores all layers when activated (REQ-008)', async () => {
|
||||||
|
const props = allOn();
|
||||||
|
render(TimelineFilters, props);
|
||||||
|
await openBar();
|
||||||
|
const reset = page.getByTestId('timeline-filter-reset');
|
||||||
|
// absent (not just hidden) while every layer is on
|
||||||
|
expect(reset.query()).toBeNull();
|
||||||
|
await page.getByTestId('timeline-filter-historical').click();
|
||||||
|
await expect.element(reset).toBeVisible();
|
||||||
|
await reset.click();
|
||||||
|
await expect
|
||||||
|
.element(page.getByTestId('timeline-filter-historical'))
|
||||||
|
.toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect.poll(() => reset.query()).toBeNull();
|
||||||
|
expect(props.onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import LetterCard from './LetterCard.svelte';
|
|||||||
import EventPill from './EventPill.svelte';
|
import EventPill from './EventPill.svelte';
|
||||||
import WorldBand from './WorldBand.svelte';
|
import WorldBand from './WorldBand.svelte';
|
||||||
import { entryKey } from './entryKey';
|
import { entryKey } from './entryKey';
|
||||||
|
import { buildEventLookup, type GroupingMode } from './timelineGrouping';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type TimelineDTO = components['schemas']['TimelineDTO'];
|
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||||
@@ -18,8 +19,28 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
|||||||
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
|
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
|
||||||
* for the per-person rail (issue #10) and is undefined here; it is not passed to
|
* for the per-person rail (issue #10) and is undefined here; it is not passed to
|
||||||
* leaf cards (REQ-025). Owns no <main> — the layout does.
|
* leaf cards (REQ-025). Owns no <main> — the layout does.
|
||||||
|
*
|
||||||
|
* `groupingMode` (#827) flows down to each YearBand to re-bundle its loose letters;
|
||||||
|
* the event lookup — the curated events present in this (already layer-filtered)
|
||||||
|
* view — is resolved once here so Ereignis clusters never reference a filtered-out
|
||||||
|
* event (filter-then-group, REQ-019). The undated bucket renders unchanged in every
|
||||||
|
* mode (its letters have no year, so the per-year bucketing does not apply).
|
||||||
*/
|
*/
|
||||||
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
|
let {
|
||||||
|
timeline,
|
||||||
|
personId = undefined,
|
||||||
|
canWrite = false,
|
||||||
|
groupingMode = 'date'
|
||||||
|
}: {
|
||||||
|
timeline: TimelineDTO;
|
||||||
|
personId?: string;
|
||||||
|
canWrite?: boolean;
|
||||||
|
groupingMode?: GroupingMode;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const eventLookup = $derived(
|
||||||
|
groupingMode === 'date' ? new Map<string, string>() : buildEventLookup(timeline)
|
||||||
|
);
|
||||||
|
|
||||||
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
|
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
|
||||||
|
|
||||||
@@ -50,7 +71,12 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
|||||||
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
||||||
<li>
|
<li>
|
||||||
{#if row.t === 'band'}
|
{#if row.t === 'band'}
|
||||||
<YearBand year={row.year} />
|
<YearBand
|
||||||
|
year={row.year}
|
||||||
|
canWrite={canWrite}
|
||||||
|
groupingMode={groupingMode}
|
||||||
|
eventLookup={eventLookup}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<GapSpan from={row.from} to={row.to} />
|
<GapSpan from={row.from} to={row.to} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -75,9 +101,9 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
|||||||
<li>
|
<li>
|
||||||
{#if entry.kind === 'EVENT'}
|
{#if entry.kind === 'EVENT'}
|
||||||
{#if entry.type === 'HISTORICAL'}
|
{#if entry.type === 'HISTORICAL'}
|
||||||
<WorldBand entry={entry} />
|
<WorldBand entry={entry} canWrite={canWrite} />
|
||||||
{:else}
|
{:else}
|
||||||
<EventPill entry={entry} />
|
<EventPill entry={entry} canWrite={canWrite} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<LetterCard entry={entry} />
|
<LetterCard entry={entry} />
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ describe('TimelineView', () => {
|
|||||||
|
|
||||||
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
|
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
|
||||||
render(TimelineView, {
|
render(TimelineView, {
|
||||||
|
canWrite: true,
|
||||||
timeline: makeTimelineDTO({
|
timeline: makeTimelineDTO({
|
||||||
undated: [
|
undated: [
|
||||||
makeEntry({
|
makeEntry({
|
||||||
@@ -125,8 +126,8 @@ describe('TimelineView', () => {
|
|||||||
// The event renders inside the undated section…
|
// The event renders inside the undated section…
|
||||||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||||||
expect(document.body.textContent).toContain('Auswanderung');
|
expect(document.body.textContent).toContain('Auswanderung');
|
||||||
// …as an EventPill (its edit affordance), never as a letter card linking
|
// …as an EventPill (its edit affordance, threaded canWrite), never as a
|
||||||
// to /documents/undefined with "Unbekannt → Unbekannt".
|
// letter card linking to /documents/undefined with "Unbekannt → Unbekannt".
|
||||||
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
|
||||||
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
||||||
expect(document.body.textContent).not.toContain('Unbekannt');
|
expect(document.body.textContent).not.toContain('Unbekannt');
|
||||||
@@ -276,4 +277,68 @@ describe('TimelineView', () => {
|
|||||||
);
|
);
|
||||||
expect(sides).toEqual(['left', 'right', 'left', 'right']);
|
expect(sides).toEqual(['left', 'right', 'left', 'right']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A curated PERSONAL event reachable through both dispatch paths: the year-band
|
||||||
|
// path (TimelineView → YearBand → EventPill) and the undated bucket
|
||||||
|
// (TimelineView → EventPill). canWrite must thread to both (REQ-009).
|
||||||
|
const curated = (eventId: string, title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId,
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const bothPaths = () =>
|
||||||
|
makeTimelineDTO({
|
||||||
|
years: [makeYear(1924, [curated('banded', 'Umzug nach Berlin')])],
|
||||||
|
undated: [curated('undated', 'Auswanderung')]
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads canWrite to a curated event in both a year band and the undated bucket (REQ-009)', () => {
|
||||||
|
render(TimelineView, { canWrite: true, timeline: bothPaths() });
|
||||||
|
const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) =>
|
||||||
|
a.getAttribute('href')
|
||||||
|
);
|
||||||
|
expect(hrefs).toContain('/zeitstrahl/events/banded/edit');
|
||||||
|
expect(hrefs).toContain('/zeitstrahl/events/undated/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no edit links in either path when canWrite is false (REQ-007/009)', () => {
|
||||||
|
render(TimelineView, { canWrite: false, timeline: bothPaths() });
|
||||||
|
expect(document.querySelectorAll('[data-testid="event-edit"]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads canWrite to a curated HISTORICAL world band in both paths (REQ-006/009)', () => {
|
||||||
|
const world = (eventId: string, title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
eventId,
|
||||||
|
precision: 'YEAR',
|
||||||
|
eventDate: '1929-01-01',
|
||||||
|
eventDateEnd: undefined,
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
render(TimelineView, {
|
||||||
|
canWrite: true,
|
||||||
|
timeline: makeTimelineDTO({
|
||||||
|
years: [makeYear(1929, [world('wb', 'Weltwirtschaftskrise')])],
|
||||||
|
undated: [world('wu', 'Unbekanntes Weltereignis')]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) =>
|
||||||
|
a.getAttribute('href')
|
||||||
|
);
|
||||||
|
expect(hrefs).toContain('/zeitstrahl/events/wb/edit');
|
||||||
|
expect(hrefs).toContain('/zeitstrahl/events/wu/edit');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|||||||
* (REQ-009). A RANGE carries a visible span pill ("1914–1918") with a Zeitraum
|
* (REQ-009). A RANGE carries a visible span pill ("1914–1918") with a Zeitraum
|
||||||
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
|
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
|
||||||
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
|
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
|
||||||
* uses text-ink-2 to stay AA in both themes (REQ-019).
|
* uses text-ink-2 to stay AA in both themes (REQ-019). A curator (`canWrite`,
|
||||||
|
* gate-closed by default) gets an inline edit pencil for a curated event with an
|
||||||
|
* eventId — #842 REQ-006/007/008; UX gate only, the #781 route guard is the boundary.
|
||||||
*/
|
*/
|
||||||
let { entry }: { entry: TimelineEntryDTO } = $props();
|
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
|
||||||
|
|
||||||
const config = $derived(getAccentConfig(entry));
|
const config = $derived(getAccentConfig(entry));
|
||||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
||||||
@@ -24,6 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
|
|||||||
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
|
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
|
||||||
// always trails the subtitle as plain text — never a second pill (REQ-009).
|
// always trails the subtitle as plain text — never a second pill (REQ-009).
|
||||||
const historical = $derived(m.timeline_layer_historical_suffix());
|
const historical = $derived(m.timeline_layer_historical_suffix());
|
||||||
|
// A HISTORICAL event is never derived, so the edit gate is just the curator
|
||||||
|
// flag plus a real eventId (#842 REQ-006/008).
|
||||||
|
const canEdit = $derived(canWrite && entry.eventId != null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
|
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
|
||||||
@@ -46,4 +51,14 @@ const historical = $derived(m.timeline_layer_historical_suffix());
|
|||||||
<!-- Single trailing "· historisch" register, after the title and any
|
<!-- Single trailing "· historisch" register, after the title and any
|
||||||
span pill / date — one render site, consistent separator (REQ-009). -->
|
span pill / date — one render site, consistent separator (REQ-009). -->
|
||||||
<span class="ml-2 font-sans text-xs text-ink-3">· {historical}</span>
|
<span class="ml-2 font-sans text-xs text-ink-3">· {historical}</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<a
|
||||||
|
data-testid="event-edit"
|
||||||
|
href="/zeitstrahl/events/{entry.eventId}/edit"
|
||||||
|
class="ml-2 rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✎</span>
|
||||||
|
<span class="sr-only">{m.btn_edit()}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,4 +73,35 @@ describe('WorldBand', () => {
|
|||||||
expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix());
|
expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix());
|
||||||
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
|
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const HIST_EVENT_ID = '44444444-4444-4444-4444-444444444444';
|
||||||
|
|
||||||
|
it('shows an edit affordance for a curated HISTORICAL event when canWrite is true (REQ-006)', () => {
|
||||||
|
render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||||
|
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
|
||||||
|
expect(edit).not.toBeNull();
|
||||||
|
expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${HIST_EVENT_ID}/edit`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirrors the EventPill pencil: aria-hidden ✎ glyph + sr-only Bearbeiten label (REQ-006)', () => {
|
||||||
|
render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||||
|
const edit = document.querySelector('[data-testid="event-edit"]');
|
||||||
|
expect(edit?.querySelector('[aria-hidden="true"]')?.textContent).toBe('✎');
|
||||||
|
expect(edit?.querySelector('.sr-only')?.textContent).toBe(m.btn_edit());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no edit affordance for a curated HISTORICAL event when canWrite is false (REQ-007)', () => {
|
||||||
|
render(WorldBand, { canWrite: false, entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no edit affordance when the canWrite prop is omitted (gate-closed default) (REQ-007)', () => {
|
||||||
|
render(WorldBand, { entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => {
|
||||||
|
render(WorldBand, { canWrite: true, entry: historical({ eventId: undefined }) });
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import EventPill from './EventPill.svelte';
|
|||||||
import WorldBand from './WorldBand.svelte';
|
import WorldBand from './WorldBand.svelte';
|
||||||
import LetterCard from './LetterCard.svelte';
|
import LetterCard from './LetterCard.svelte';
|
||||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||||
|
import LetterBucket from './LetterBucket.svelte';
|
||||||
import { isDense } from './timelineDensity';
|
import { isDense } from './timelineDensity';
|
||||||
import { entryKey } from './entryKey';
|
import { entryKey } from './entryKey';
|
||||||
|
import {
|
||||||
|
bucketLetters,
|
||||||
|
type GroupingMode,
|
||||||
|
type LetterBucket as LetterBucketModel
|
||||||
|
} from './timelineGrouping';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
||||||
@@ -15,19 +21,80 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|||||||
* render in DTO order as pills/bands; letters render as individual cards while
|
* render in DTO order as pills/bands; letters render as individual cards while
|
||||||
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
|
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
|
||||||
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
|
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
|
||||||
|
*
|
||||||
|
* In Ereignis/Thema mode (#827) the event pills/world-bands render identically
|
||||||
|
* (REQ-001); only the loose letters re-bundle into per-year buckets below them
|
||||||
|
* (REQ-002/003/004). Datum mode is the original individual-card / density-strip
|
||||||
|
* path, untouched.
|
||||||
*/
|
*/
|
||||||
let { year }: { year: TimelineYearDTO } = $props();
|
let {
|
||||||
|
year,
|
||||||
|
canWrite = false,
|
||||||
|
groupingMode = 'date',
|
||||||
|
eventLookup = new Map<string, string>()
|
||||||
|
}: {
|
||||||
|
year: TimelineYearDTO;
|
||||||
|
canWrite?: boolean;
|
||||||
|
groupingMode?: GroupingMode;
|
||||||
|
eventLookup?: Map<string, string>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
type Row =
|
type Row =
|
||||||
| { t: 'event'; entry: TimelineEntryDTO }
|
| { t: 'event'; entry: TimelineEntryDTO }
|
||||||
|
| { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
|
||||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
||||||
| { t: 'strip' };
|
| { t: 'strip' }
|
||||||
|
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
|
||||||
|
|
||||||
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
|
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
|
||||||
const dense = $derived(isDense(letters.length));
|
const dense = $derived(isDense(letters.length));
|
||||||
|
const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
|
||||||
|
|
||||||
const rows = $derived.by<Row[]>(() => {
|
const rows = $derived.by<Row[]>(() => {
|
||||||
const out: Row[] = [];
|
const out: Row[] = [];
|
||||||
|
|
||||||
|
// Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
|
||||||
|
// becomes the contained card's header (no separate pill — its title reads once, #827
|
||||||
|
// redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
|
||||||
|
// lives in another year band (or was filtered out) renders as a text-header card here, and
|
||||||
|
// the unlinked letters fall to the single "Weitere Briefe" drawer (REQ-003/006/019).
|
||||||
|
if (groupingMode === 'event') {
|
||||||
|
const buckets = bucketLetters(letters, 'event', eventLookup);
|
||||||
|
const sameYearBucket = (id: string | undefined) =>
|
||||||
|
id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
|
||||||
|
for (const entry of year.entries) {
|
||||||
|
if (entry.kind !== 'EVENT') continue;
|
||||||
|
const bucket = sameYearBucket(entry.eventId);
|
||||||
|
// A curated event with same-year letters becomes the card header (card replaces pill);
|
||||||
|
// otherwise it stays a plain pill/world-band.
|
||||||
|
if (bucket) out.push({ t: 'eventcard', entry, bucket });
|
||||||
|
else out.push({ t: 'event', entry });
|
||||||
|
}
|
||||||
|
// Cross-year clusters (no matching event entry in this band) and the fallback drawer
|
||||||
|
// render after the axis entries, with their own text header.
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (
|
||||||
|
bucket.kind === 'fallback' ||
|
||||||
|
!year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key)
|
||||||
|
) {
|
||||||
|
out.push({ t: 'bucket', bucket, nested: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thema: events stay on the axis (REQ-001); loose letters re-bundle into per-year root-tag
|
||||||
|
// buckets below them (REQ-004) — no axis pill exists for a tag, so every bucket keeps a header.
|
||||||
|
if (groupingMode === 'thema') {
|
||||||
|
for (const entry of year.entries) {
|
||||||
|
if (entry.kind === 'EVENT') out.push({ t: 'event', entry });
|
||||||
|
}
|
||||||
|
for (const bucket of bucketLetters(letters, 'thema', eventLookup)) {
|
||||||
|
out.push({ t: 'bucket', bucket, nested: false });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
let stripInserted = false;
|
let stripInserted = false;
|
||||||
let letterIndex = 0;
|
let letterIndex = 0;
|
||||||
for (const entry of year.entries) {
|
for (const entry of year.entries) {
|
||||||
@@ -43,6 +110,12 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function rowKey(row: Row): string {
|
||||||
|
if (row.t === 'strip') return `strip-${year.year}`;
|
||||||
|
if (row.t === 'bucket') return row.bucket.key;
|
||||||
|
return entryKey(row.entry);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="py-2">
|
<section class="py-2">
|
||||||
@@ -56,18 +129,28 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="mt-3 space-y-3">
|
<div class="mt-3 space-y-3">
|
||||||
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
|
{#each rows as row (rowKey(row))}
|
||||||
{#if row.t === 'event'}
|
{#if row.t === 'event'}
|
||||||
{#if row.entry.type === 'HISTORICAL'}
|
{#if row.entry.type === 'HISTORICAL'}
|
||||||
<WorldBand entry={row.entry} />
|
<WorldBand entry={row.entry} canWrite={canWrite} />
|
||||||
{:else}
|
{:else}
|
||||||
<EventPill entry={row.entry} />
|
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if row.t === 'eventcard'}
|
||||||
|
<LetterBucket
|
||||||
|
bucket={row.bucket}
|
||||||
|
mode="event"
|
||||||
|
year={year.year}
|
||||||
|
event={row.entry}
|
||||||
|
canWrite={canWrite}
|
||||||
|
/>
|
||||||
{:else if row.t === 'letter'}
|
{:else if row.t === 'letter'}
|
||||||
<div class="letter-row" data-side={row.side}>
|
<div class="letter-row" data-side={row.side}>
|
||||||
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||||
<LetterCard entry={row.entry} />
|
<LetterCard entry={row.entry} />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if row.t === 'bucket'}
|
||||||
|
<LetterBucket bucket={row.bucket} mode={bucketMode} year={year.year} nested={row.nested} />
|
||||||
{:else}
|
{:else}
|
||||||
<YearLetterStrip letters={letters} year={year.year} />
|
<YearLetterStrip letters={letters} year={year.year} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -165,3 +165,100 @@ describe('YearBand', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('YearBand — grouping modes (#827)', () => {
|
||||||
|
it('keeps individual letter cards and no buckets in Datum mode (default)', () => {
|
||||||
|
render(YearBand, { year: makeYear(1915, manyLetters(1915, 3)) });
|
||||||
|
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
|
||||||
|
expect(document.querySelectorAll('a')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clusters loose letters under their linked event in Ereignis mode (REQ-002/003)', () => {
|
||||||
|
const a = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1915-03-01' });
|
||||||
|
const b = makeEntry({ documentId: 'b', linkedEventId: 'e1', eventDate: '1915-04-01' });
|
||||||
|
render(YearBand, {
|
||||||
|
year: makeYear(1915, [a, b]),
|
||||||
|
groupingMode: 'event',
|
||||||
|
eventLookup: new Map([['e1', 'Briefe von der Front']])
|
||||||
|
});
|
||||||
|
expect(document.querySelectorAll('[data-testid="letter-bucket"]')).toHaveLength(1);
|
||||||
|
expect(document.body.textContent).toContain('Briefe von der Front');
|
||||||
|
// no alternating individual letter rows in grouped mode
|
||||||
|
expect(document.querySelector('.letter-row')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders the event world-band in Ereignis mode (REQ-001)', () => {
|
||||||
|
const band = makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
precision: 'RANGE',
|
||||||
|
eventDate: '1914-01-01',
|
||||||
|
eventDateEnd: '1918-12-31',
|
||||||
|
title: 'Erster Weltkrieg',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1914-05-01' });
|
||||||
|
render(YearBand, {
|
||||||
|
year: makeYear(1914, [band, letter]),
|
||||||
|
groupingMode: 'event',
|
||||||
|
eventLookup: new Map([['e1', 'Front']])
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buckets loose letters under their root tag in Thema mode (REQ-004)', () => {
|
||||||
|
const a = makeEntry({
|
||||||
|
documentId: 'a',
|
||||||
|
rootTagId: 't1',
|
||||||
|
rootTagName: 'Krieg',
|
||||||
|
rootTagColor: 'sienna',
|
||||||
|
eventDate: '1915-03-01'
|
||||||
|
});
|
||||||
|
render(YearBand, { year: makeYear(1915, [a]), groupingMode: 'thema', eventLookup: new Map() });
|
||||||
|
const chip = document.querySelector('[data-testid="bucket-header-chip"]');
|
||||||
|
expect(chip?.textContent).toContain('Krieg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a same-year curated event as one card header, with no separate pill and no duplicate title (#827)', () => {
|
||||||
|
const pill = makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'e1',
|
||||||
|
title: 'Ein gewaltiger Stadtbrand',
|
||||||
|
eventDate: '1916-07-06',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1916-07-06' });
|
||||||
|
render(YearBand, {
|
||||||
|
year: makeYear(1916, [pill, letter]),
|
||||||
|
groupingMode: 'event',
|
||||||
|
eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]),
|
||||||
|
canWrite: true
|
||||||
|
});
|
||||||
|
// the title appears exactly once — in the card header, not also as a separate pill
|
||||||
|
const occurrences =
|
||||||
|
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
||||||
|
expect(occurrences).toBe(1);
|
||||||
|
// the event renders as the card header, with its letter clustered inside
|
||||||
|
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a header on an event cluster whose pill is in another year (#827)', () => {
|
||||||
|
// the letter links to e1, but e1's pill lives in a different band — so the cluster
|
||||||
|
// keeps its own header here (no pill nearby to duplicate).
|
||||||
|
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1917-02-01' });
|
||||||
|
render(YearBand, {
|
||||||
|
year: makeYear(1917, [letter]),
|
||||||
|
groupingMode: 'event',
|
||||||
|
eventLookup: new Map([['e1', 'Briefe von der Front']])
|
||||||
|
});
|
||||||
|
expect(document.body.textContent).toContain('Briefe von der Front');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import TimelineView from './TimelineView.svelte';
|
||||||
|
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||||
|
import type { GroupingMode } from './timelineGrouping';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const worldBand = (title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
precision: 'RANGE',
|
||||||
|
eventDate: '1914-01-01',
|
||||||
|
eventDateEnd: '1918-12-31',
|
||||||
|
eventId: 'h1',
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventPill = (title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'p1',
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// A signature of the axis-fixed event layer: the curated/world-band titles, the world-range
|
||||||
|
// marker count, and the event-pill count — everything REQ-001 requires to stay constant when
|
||||||
|
// only the loose letters re-bundle. (No pixel-diff harness in the repo; this is the structural
|
||||||
|
// equivalent — the event-layer DOM is byte-for-byte built from the same entries in every mode.)
|
||||||
|
function eventLayerSignature(): string {
|
||||||
|
const body = document.body.textContent ?? '';
|
||||||
|
return JSON.stringify({
|
||||||
|
weltkrieg: body.includes('Erster Weltkrieg'),
|
||||||
|
hochzeit: body.includes('Hochzeit'),
|
||||||
|
worldRange: document.querySelectorAll('[data-testid="world-range"]').length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief A links to the curated event p1 (Hochzeit), not the world band — so the world band
|
||||||
|
// stays letterless and renders as a plain band in every mode (REQ-001). Under the #827 redesign
|
||||||
|
// a curated event WITH letters becomes its cluster card's header, so the signature tracks the
|
||||||
|
// stable layer: the letterless world band's marker count and the two titles, which all survive
|
||||||
|
// regardless of whether Hochzeit renders as a pill (Datum) or a card header (grouped).
|
||||||
|
const mixed = () =>
|
||||||
|
makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [
|
||||||
|
worldBand('Erster Weltkrieg'),
|
||||||
|
eventPill('Hochzeit'),
|
||||||
|
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }),
|
||||||
|
makeEntry({
|
||||||
|
documentId: 'b',
|
||||||
|
title: 'Brief B',
|
||||||
|
rootTagId: 't1',
|
||||||
|
rootTagName: 'Krieg',
|
||||||
|
rootTagColor: 'sienna'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
function signatureFor(mode: GroupingMode): string {
|
||||||
|
render(TimelineView, { timeline: mixed(), groupingMode: mode });
|
||||||
|
const sig = eventLayerSignature();
|
||||||
|
cleanup();
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TimelineView event layer (REQ-001)', () => {
|
||||||
|
it('renders the event pills and world-bands identically across all three grouping modes', () => {
|
||||||
|
const dateSig = signatureFor('date');
|
||||||
|
const eventSig = signatureFor('event');
|
||||||
|
const themaSig = signatureFor('thema');
|
||||||
|
|
||||||
|
expect(eventSig).toBe(dateSig);
|
||||||
|
expect(themaSig).toBe(dateSig);
|
||||||
|
// sanity: the world-band actually rendered, so the assertion is not vacuously equal on ""
|
||||||
|
expect(dateSig).toContain('"worldRange":1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regroups only the loose letters — buckets appear off Datum, not in it', () => {
|
||||||
|
render(TimelineView, { timeline: mixed(), groupingMode: 'date' });
|
||||||
|
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
|
||||||
|
cleanup();
|
||||||
|
render(TimelineView, { timeline: mixed(), groupingMode: 'event' });
|
||||||
|
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
Normal file
23
frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const timelineDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REQ-009 / CWE-79: the regroup touches every component under lib/timeline (the reused TagChip,
|
||||||
|
* the .lcard.ev card, and the new tinted bucket-header chip). Curator/import-derived text must
|
||||||
|
* always render through Svelte's default `{...}` escaping — never `{@html}`. This grep gate fails
|
||||||
|
* loudly the moment any timeline component reaches for the raw-HTML directive.
|
||||||
|
*/
|
||||||
|
describe('lib/timeline never uses {@html} (REQ-009)', () => {
|
||||||
|
it('no timeline component contains the raw-HTML directive', () => {
|
||||||
|
const components = readdirSync(timelineDir).filter((file) => file.endsWith('.svelte'));
|
||||||
|
expect(components.length).toBeGreaterThan(0);
|
||||||
|
const offenders = components.filter((file) =>
|
||||||
|
readFileSync(join(timelineDir, file), 'utf8').includes('{@html')
|
||||||
|
);
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
151
frontend/src/lib/timeline/timelineFilter.spec.ts
Normal file
151
frontend/src/lib/timeline/timelineFilter.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isDefaultState,
|
||||||
|
hiddenLayerCount,
|
||||||
|
filterTimeline,
|
||||||
|
ALL_LAYERS_ON,
|
||||||
|
type TimelineLayerFilters
|
||||||
|
} from './timelineFilter';
|
||||||
|
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||||
|
|
||||||
|
// Entry factories pinned to the three layers the filter discriminates (#780).
|
||||||
|
const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides });
|
||||||
|
|
||||||
|
const curatedPersonal = (overrides = {}) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Umzug nach Berlin',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived life-events carry type=PERSONAL (issue #776 REQ-009) — they belong to
|
||||||
|
// the Personal layer, not a fourth one.
|
||||||
|
const derivedLifeEvent = (overrides = {}) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: true,
|
||||||
|
derivedType: 'BIRTH',
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Geburt',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const historical = (overrides = {}) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Erster Weltkrieg',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const off = (overrides: Partial<TimelineLayerFilters>): TimelineLayerFilters => ({
|
||||||
|
...ALL_LAYERS_ON,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isDefaultState (REQ-007)', () => {
|
||||||
|
it('is true when all three layers are on', () => {
|
||||||
|
expect(isDefaultState(ALL_LAYERS_ON)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when any single layer is off', () => {
|
||||||
|
expect(isDefaultState(off({ personalOn: false }))).toBe(false);
|
||||||
|
expect(isDefaultState(off({ historicalOn: false }))).toBe(false);
|
||||||
|
expect(isDefaultState(off({ lettersOn: false }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hiddenLayerCount (REQ-007)', () => {
|
||||||
|
it('is 0 in the default all-on state', () => {
|
||||||
|
expect(hiddenLayerCount(ALL_LAYERS_ON)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts each layer that is off', () => {
|
||||||
|
expect(hiddenLayerCount(off({ lettersOn: false }))).toBe(1);
|
||||||
|
expect(hiddenLayerCount(off({ personalOn: false, historicalOn: false }))).toBe(2);
|
||||||
|
expect(hiddenLayerCount({ personalOn: false, historicalOn: false, lettersOn: false })).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterTimeline', () => {
|
||||||
|
it('returns every entry unchanged in the default all-on state', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal()])],
|
||||||
|
undated: [letter({ documentId: 'u1' })]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, ALL_LAYERS_ON);
|
||||||
|
expect(result.years[0].entries).toHaveLength(3);
|
||||||
|
expect(result.undated).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides LETTER entries when lettersOn is false, keeping events (REQ-005)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal()])],
|
||||||
|
undated: [letter({ documentId: 'u1' })]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ lettersOn: false }));
|
||||||
|
expect(result.years[0].entries.every((e) => e.kind !== 'LETTER')).toBe(true);
|
||||||
|
expect(result.years[0].entries).toHaveLength(2);
|
||||||
|
expect(result.undated).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides HISTORICAL events when historicalOn is false, keeping personal + letters (REQ-004)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ historicalOn: false }));
|
||||||
|
const kept = result.years[0].entries;
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(false);
|
||||||
|
expect(kept.some((e) => e.kind === 'LETTER')).toBe(true);
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(true);
|
||||||
|
expect(kept).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides personal events — curated and derived — when personalOn is false (REQ-003)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [letter(), historical(), curatedPersonal(), derivedLifeEvent()])]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ personalOn: false }));
|
||||||
|
const kept = result.years[0].entries;
|
||||||
|
// neither the curated PERSONAL event nor the derived life-event survives
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'PERSONAL')).toBe(false);
|
||||||
|
expect(kept.some((e) => e.derived)).toBe(false);
|
||||||
|
// historical events and letters are untouched
|
||||||
|
expect(kept.some((e) => e.kind === 'EVENT' && e.type === 'HISTORICAL')).toBe(true);
|
||||||
|
expect(kept.some((e) => e.kind === 'LETTER')).toBe(true);
|
||||||
|
expect(kept).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops year bands that become empty and filters the undated bucket (REQ-006)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [letter()]), // becomes empty when letters are hidden
|
||||||
|
makeYear(1918, [historical()]) // survives
|
||||||
|
],
|
||||||
|
undated: [letter({ documentId: 'u1' }), historical({ documentId: undefined })]
|
||||||
|
});
|
||||||
|
const result = filterTimeline(dto, off({ lettersOn: false }));
|
||||||
|
expect(result.years).toHaveLength(1);
|
||||||
|
expect(result.years[0].year).toBe(1918);
|
||||||
|
expect(result.undated.every((e) => e.kind !== 'LETTER')).toBe(true);
|
||||||
|
expect(result.undated).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the input timeline', () => {
|
||||||
|
const dto = makeTimelineDTO({ years: [makeYear(1915, [letter(), historical()])] });
|
||||||
|
filterTimeline(dto, off({ lettersOn: false }));
|
||||||
|
expect(dto.years[0].entries).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/lib/timeline/timelineFilter.ts
Normal file
63
frontend/src/lib/timeline/timelineFilter.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||||
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three visibility layers a reader can toggle on the global `/zeitstrahl`
|
||||||
|
* (#780). Purely a presentation concern — the whole timeline is loaded once by
|
||||||
|
* #779; these toggles derive a client-side filtered view of it.
|
||||||
|
*/
|
||||||
|
export interface TimelineLayerFilters {
|
||||||
|
/** Personal events — curated `PERSONAL` events and derived life-events. */
|
||||||
|
personalOn: boolean;
|
||||||
|
/** Historical events (`type === 'HISTORICAL'`). */
|
||||||
|
historicalOn: boolean;
|
||||||
|
/** Letters (`kind === 'LETTER'`). */
|
||||||
|
lettersOn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default view: every layer visible. */
|
||||||
|
export const ALL_LAYERS_ON: TimelineLayerFilters = {
|
||||||
|
personalOn: true,
|
||||||
|
historicalOn: true,
|
||||||
|
lettersOn: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when no layer is hidden — the default, all-on state (REQ-007). */
|
||||||
|
export function isDefaultState(filters: TimelineLayerFilters): boolean {
|
||||||
|
return filters.personalOn && filters.historicalOn && filters.lettersOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many layers are currently hidden — the "N active" trigger count (REQ-007). */
|
||||||
|
export function hiddenLayerCount(filters: TimelineLayerFilters): number {
|
||||||
|
return (
|
||||||
|
(filters.personalOn ? 0 : 1) + (filters.historicalOn ? 0 : 1) + (filters.lettersOn ? 0 : 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether one entry survives the active layer toggles. A letter rides
|
||||||
|
* the Letters layer; a historical event the Historical layer; everything else
|
||||||
|
* (curated `PERSONAL` events and derived life-events, which also carry
|
||||||
|
* `type === 'PERSONAL'`) the Personal layer.
|
||||||
|
*/
|
||||||
|
function isVisible(entry: TimelineEntryDTO, filters: TimelineLayerFilters): boolean {
|
||||||
|
if (entry.kind === 'LETTER') return filters.lettersOn;
|
||||||
|
if (entry.type === 'HISTORICAL') return filters.historicalOn;
|
||||||
|
return filters.personalOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a client-side filtered copy of the timeline (REQ-003/004/005/006).
|
||||||
|
* Year bands left empty by the active toggles are dropped so `TimelineView`
|
||||||
|
* never renders a hollow band, and the undated bucket is filtered the same way.
|
||||||
|
* Pure — the input DTO is never mutated.
|
||||||
|
*/
|
||||||
|
export function filterTimeline(timeline: TimelineDTO, filters: TimelineLayerFilters): TimelineDTO {
|
||||||
|
const years = timeline.years
|
||||||
|
.map((band) => ({ ...band, entries: band.entries.filter((e) => isVisible(e, filters)) }))
|
||||||
|
.filter((band) => band.entries.length > 0);
|
||||||
|
const undated = timeline.undated.filter((e) => isVisible(e, filters));
|
||||||
|
return { years, undated };
|
||||||
|
}
|
||||||
37
frontend/src/lib/timeline/timelineFilterBoundary.spec.ts
Normal file
37
frontend/src/lib/timeline/timelineFilterBoundary.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// REQ-001/002: the layer filter is presentation-only and fully client-side. It
|
||||||
|
// must never navigate or fetch — the route derives the filtered view from
|
||||||
|
// already-loaded data. This static guard mirrors the project's existing
|
||||||
|
// grep-gates (e.g. the no-`{@html}` checks) and fails the build if a future
|
||||||
|
// edit reintroduces navigation or a network call into either file.
|
||||||
|
const read = (relative: string) =>
|
||||||
|
readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8');
|
||||||
|
|
||||||
|
const FILES = {
|
||||||
|
'TimelineFilters.svelte': read('./TimelineFilters.svelte'),
|
||||||
|
'/zeitstrahl/+page.svelte': read('../../routes/zeitstrahl/+page.svelte')
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORBIDDEN: { label: string; pattern: RegExp }[] = [
|
||||||
|
{ label: 'goto(', pattern: /\bgoto\s*\(/ },
|
||||||
|
{ label: 'url.searchParams', pattern: /url\.searchParams/ },
|
||||||
|
{ label: 'api.GET', pattern: /\bapi\.GET\b/ },
|
||||||
|
{ label: 'fetch(', pattern: /\bfetch\s*\(/ }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('layer-filter boundary (REQ-001/002)', () => {
|
||||||
|
for (const [name, source] of Object.entries(FILES)) {
|
||||||
|
it(`${name} was found and is non-empty`, () => {
|
||||||
|
expect(source.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { label, pattern } of FORBIDDEN) {
|
||||||
|
it(`${name} contains no ${label}`, () => {
|
||||||
|
expect(pattern.test(source), `${name} must not use ${label}`).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
157
frontend/src/lib/timeline/timelineGrouping.spec.ts
Normal file
157
frontend/src/lib/timeline/timelineGrouping.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildEventLookup, bucketLetters, hasLooseLetters } from './timelineGrouping';
|
||||||
|
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||||
|
|
||||||
|
// Entry factories pinned to the shapes the grouping transform discriminates (#827).
|
||||||
|
const letter = (overrides = {}) => makeEntry({ kind: 'LETTER', ...overrides });
|
||||||
|
|
||||||
|
const curatedEvent = (id: string, title: string, overrides = {}) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
documentId: undefined,
|
||||||
|
eventId: id,
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildEventLookup (REQ-019)', () => {
|
||||||
|
it('collects curated events (eventId set) from year bands and the undated bucket', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [makeYear(1915, [curatedEvent('e1', 'Briefe von der Front'), letter()])],
|
||||||
|
undated: [curatedEvent('e2', 'Unbekanntes Ereignis')]
|
||||||
|
});
|
||||||
|
const lookup = buildEventLookup(dto);
|
||||||
|
expect(lookup.get('e1')).toBe('Briefe von der Front');
|
||||||
|
expect(lookup.get('e2')).toBe('Unbekanntes Ereignis');
|
||||||
|
expect(lookup.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores letters and derived life-events (no eventId)', () => {
|
||||||
|
const dto = makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [
|
||||||
|
letter({ linkedEventId: 'e1' }),
|
||||||
|
makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: true, eventId: undefined })
|
||||||
|
])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(buildEventLookup(dto).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasLooseLetters (REQ-018)', () => {
|
||||||
|
it('is true when a year band or the undated bucket holds a letter', () => {
|
||||||
|
expect(hasLooseLetters(makeTimelineDTO({ years: [makeYear(1915, [letter()])] }))).toBe(true);
|
||||||
|
expect(hasLooseLetters(makeTimelineDTO({ undated: [letter({ documentId: 'u1' })] }))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when only events remain', () => {
|
||||||
|
const dto = makeTimelineDTO({ years: [makeYear(1915, [curatedEvent('e1', 'Ereignis')])] });
|
||||||
|
expect(hasLooseLetters(dto)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bucketLetters — Ereignis mode (REQ-003/006/019)', () => {
|
||||||
|
const lookup = new Map([
|
||||||
|
['e1', 'Briefe von der Front'],
|
||||||
|
['e2', 'Weihnachten 1915']
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('clusters letters under the curated event named by linkedEventId, with matching counts', () => {
|
||||||
|
const letters = [
|
||||||
|
letter({ documentId: 'a', linkedEventId: 'e1' }),
|
||||||
|
letter({ documentId: 'b', linkedEventId: 'e1' }),
|
||||||
|
letter({ documentId: 'c', linkedEventId: 'e2' })
|
||||||
|
];
|
||||||
|
const buckets = bucketLetters(letters, 'event', lookup);
|
||||||
|
const front = buckets.find((b) => b.title === 'Briefe von der Front');
|
||||||
|
expect(front?.kind).toBe('event');
|
||||||
|
expect(front?.letters).toHaveLength(2);
|
||||||
|
expect(buckets.find((b) => b.title === 'Weihnachten 1915')?.letters).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a letter with no linkedEventId into the fallback bucket (REQ-006)', () => {
|
||||||
|
const letters = [letter({ documentId: 'a', linkedEventId: undefined })];
|
||||||
|
const buckets = bucketLetters(letters, 'event', lookup);
|
||||||
|
expect(buckets).toHaveLength(1);
|
||||||
|
expect(buckets[0].kind).toBe('fallback');
|
||||||
|
expect(buckets[0].letters).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a letter whose linked event is absent from the lookup into fallback (REQ-019)', () => {
|
||||||
|
// e9 is not in the filtered view (its layer was toggled off) → no cluster.
|
||||||
|
const letters = [letter({ documentId: 'a', linkedEventId: 'e9' })];
|
||||||
|
const buckets = bucketLetters(letters, 'event', lookup);
|
||||||
|
expect(buckets).toHaveLength(1);
|
||||||
|
expect(buckets[0].kind).toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the fallback bucket last', () => {
|
||||||
|
const letters = [
|
||||||
|
letter({ documentId: 'a', linkedEventId: undefined }),
|
||||||
|
letter({ documentId: 'b', linkedEventId: 'e1' })
|
||||||
|
];
|
||||||
|
const buckets = bucketLetters(letters, 'event', lookup);
|
||||||
|
expect(buckets[buckets.length - 1].kind).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bucketLetters — Thema mode (REQ-004/007/008)', () => {
|
||||||
|
const noEvents = new Map<string, string>();
|
||||||
|
|
||||||
|
it('buckets letters under their primary root tag with name and colour', () => {
|
||||||
|
const letters = [
|
||||||
|
letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }),
|
||||||
|
letter({ documentId: 'b', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' }),
|
||||||
|
letter({
|
||||||
|
documentId: 'c',
|
||||||
|
rootTagId: 't2',
|
||||||
|
rootTagName: 'Weihnachten',
|
||||||
|
rootTagColor: 'amber'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
const buckets = bucketLetters(letters, 'thema', noEvents);
|
||||||
|
const krieg = buckets.find((b) => b.title === 'Krieg');
|
||||||
|
expect(krieg?.kind).toBe('tag');
|
||||||
|
expect(krieg?.color).toBe('sienna');
|
||||||
|
expect(krieg?.letters).toHaveLength(2);
|
||||||
|
expect(buckets.find((b) => b.title === 'Weihnachten')?.color).toBe('amber');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an untagged letter into the "Ohne Thema" fallback bucket (REQ-007)', () => {
|
||||||
|
const letters = [letter({ documentId: 'a', rootTagId: undefined })];
|
||||||
|
const buckets = bucketLetters(letters, 'thema', noEvents);
|
||||||
|
expect(buckets).toHaveLength(1);
|
||||||
|
expect(buckets[0].kind).toBe('fallback');
|
||||||
|
expect(buckets[0].color).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a letter in exactly one bucket (REQ-008)', () => {
|
||||||
|
const letters = [
|
||||||
|
letter({ documentId: 'a', rootTagId: 't1', rootTagName: 'Krieg', rootTagColor: 'sienna' })
|
||||||
|
];
|
||||||
|
const buckets = bucketLetters(letters, 'thema', noEvents);
|
||||||
|
const occurrences = buckets.flatMap((b) => b.letters).filter((l) => l.documentId === 'a');
|
||||||
|
expect(occurrences).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a null colour through for a colourless root tag', () => {
|
||||||
|
const letters = [
|
||||||
|
letter({
|
||||||
|
documentId: 'a',
|
||||||
|
rootTagId: 't3',
|
||||||
|
rootTagName: 'Allgemein',
|
||||||
|
rootTagColor: undefined
|
||||||
|
})
|
||||||
|
];
|
||||||
|
const buckets = bucketLetters(letters, 'thema', noEvents);
|
||||||
|
expect(buckets[0].kind).toBe('tag');
|
||||||
|
expect(buckets[0].color).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
152
frontend/src/lib/timeline/timelineGrouping.ts
Normal file
152
frontend/src/lib/timeline/timelineGrouping.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||||
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three ways a reader can bundle the loose letters on `/zeitstrahl` (#827). The
|
||||||
|
* axis-fixed layers (life-events, event pills, world-bands) are identical in every mode
|
||||||
|
* — only loose-letter bundling changes. Grouping runs over the *already layer-filtered*
|
||||||
|
* timeline (#780): filter-then-group.
|
||||||
|
*/
|
||||||
|
export type GroupingMode = 'date' | 'event' | 'thema';
|
||||||
|
|
||||||
|
/** The default mode — chronological, as #779 shipped. */
|
||||||
|
export const DEFAULT_GROUPING: GroupingMode = 'date';
|
||||||
|
|
||||||
|
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
|
||||||
|
export const CLUSTER_PREVIEW = 5;
|
||||||
|
|
||||||
|
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
||||||
|
const TAG_COLOR_TOKENS = new Set([
|
||||||
|
'sage',
|
||||||
|
'sienna',
|
||||||
|
'amber',
|
||||||
|
'slate',
|
||||||
|
'violet',
|
||||||
|
'rose',
|
||||||
|
'cobalt',
|
||||||
|
'moss',
|
||||||
|
'sand',
|
||||||
|
'coral'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a root-tag colour-name token to its CSS variable reference, or `null` for an absent
|
||||||
|
* or unknown token (so a colourless/unrecognised tag falls back to a neutral rail, never a
|
||||||
|
* broken `var(--c-tag-undefined)`).
|
||||||
|
*/
|
||||||
|
export function tagColorVar(token: string | null | undefined): string | null {
|
||||||
|
return token && TAG_COLOR_TOKENS.has(token) ? `var(--c-tag-${token})` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bundle of loose letters under a single header, within a year (Ereignis/Thema modes).
|
||||||
|
* `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized
|
||||||
|
* fallback ("Weitere Briefe" / "Ohne Thema") the view supplies for `kind === 'fallback'`.
|
||||||
|
*/
|
||||||
|
export interface LetterBucket {
|
||||||
|
/** Stable `{#each}` key, unique within a year's bucket list. */
|
||||||
|
key: string;
|
||||||
|
kind: 'event' | 'tag' | 'fallback';
|
||||||
|
/** Header label for `event`/`tag` buckets; absent for `fallback` (view supplies a localized label). */
|
||||||
|
title?: string;
|
||||||
|
/** Root-tag colour token for a `tag` bucket; `null` for `event`/`fallback` (neutral). */
|
||||||
|
color: string | null;
|
||||||
|
letters: TimelineEntryDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps each curated event present in the (already-filtered) timeline to its title. These are the
|
||||||
|
* only events a letter may cluster under — a letter whose `linkedEventId` is absent here links to
|
||||||
|
* an event the layer filter removed, so it falls back to "Weitere Briefe" (filter-then-group,
|
||||||
|
* REQ-019). Curated events carry an `eventId`; derived life-events and letters do not.
|
||||||
|
*/
|
||||||
|
export function buildEventLookup(timeline: TimelineDTO): Map<string, string> {
|
||||||
|
const lookup = new Map<string, string>();
|
||||||
|
const collect = (entries: TimelineEntryDTO[]) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.kind === 'EVENT' && entry.eventId) lookup.set(entry.eventId, entry.title ?? '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const band of timeline.years) collect(band.entries);
|
||||||
|
collect(timeline.undated);
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the timeline still holds at least one loose letter. Drives the grouping control's
|
||||||
|
* enabled state: with the Letters layer filtered off there is nothing to regroup (REQ-018).
|
||||||
|
*/
|
||||||
|
export function hasLooseLetters(timeline: TimelineDTO): boolean {
|
||||||
|
const holdsLetter = (entries: TimelineEntryDTO[]) => entries.some((e) => e.kind === 'LETTER');
|
||||||
|
return timeline.years.some((band) => holdsLetter(band.entries)) || holdsLetter(timeline.undated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets one year's loose letters for Ereignis/Thema mode. The caller passes only that year's
|
||||||
|
* `LETTER` entries; events stay on the axis untouched (REQ-001). Buckets keep first-seen order and
|
||||||
|
* the fallback bucket, if any, always sorts last.
|
||||||
|
*
|
||||||
|
* - `event`: cluster under `linkedEventId` when it is set AND survives in `eventLookup`; otherwise
|
||||||
|
* the fallback "Weitere Briefe" bucket (REQ-003/006/019).
|
||||||
|
* - `thema`: bucket under `rootTagId` (header = `rootTagName`, tint = `rootTagColor`); an untagged
|
||||||
|
* letter goes to the fallback "Ohne Thema" bucket (REQ-004/007). A letter carries exactly one
|
||||||
|
* `rootTagId`, so it lands in exactly one bucket (REQ-008).
|
||||||
|
*/
|
||||||
|
export function bucketLetters(
|
||||||
|
letters: TimelineEntryDTO[],
|
||||||
|
mode: Exclude<GroupingMode, 'date'>,
|
||||||
|
eventLookup: Map<string, string>
|
||||||
|
): LetterBucket[] {
|
||||||
|
const byKey = new Map<string, LetterBucket>();
|
||||||
|
let fallback: LetterBucket | null = null;
|
||||||
|
|
||||||
|
const fallbackBucket = (): LetterBucket => {
|
||||||
|
if (!fallback) fallback = { key: '__fallback__', kind: 'fallback', color: null, letters: [] };
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const namedBucket = (id: string, build: () => LetterBucket): LetterBucket => {
|
||||||
|
let bucket = byKey.get(id);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = build();
|
||||||
|
byKey.set(id, bucket);
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const letter of letters) {
|
||||||
|
if (mode === 'event') {
|
||||||
|
const id = letter.linkedEventId;
|
||||||
|
if (id && eventLookup.has(id)) {
|
||||||
|
namedBucket(id, () => ({
|
||||||
|
key: `event:${id}`,
|
||||||
|
kind: 'event',
|
||||||
|
title: eventLookup.get(id),
|
||||||
|
color: null,
|
||||||
|
letters: []
|
||||||
|
})).letters.push(letter);
|
||||||
|
} else {
|
||||||
|
fallbackBucket().letters.push(letter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const id = letter.rootTagId;
|
||||||
|
if (id) {
|
||||||
|
namedBucket(id, () => ({
|
||||||
|
key: `tag:${id}`,
|
||||||
|
kind: 'tag',
|
||||||
|
title: letter.rootTagName ?? '',
|
||||||
|
color: letter.rootTagColor ?? null,
|
||||||
|
letters: []
|
||||||
|
})).letters.push(letter);
|
||||||
|
} else {
|
||||||
|
fallbackBucket().letters.push(letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = [...byKey.values()];
|
||||||
|
if (fallback) buckets.push(fallback);
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
@@ -128,7 +128,8 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Edit button — full width, outlined -->
|
<!-- Curator actions — full width, outlined. Both links are gated to
|
||||||
|
WRITE_ALL; the gate is UX only (the #781 route guard is the boundary). -->
|
||||||
{#if canWrite}
|
{#if canWrite}
|
||||||
<a
|
<a
|
||||||
href="/persons/{person.id}/edit"
|
href="/persons/{person.id}/edit"
|
||||||
@@ -142,6 +143,15 @@ const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrec
|
|||||||
/>
|
/>
|
||||||
{m.btn_edit()}
|
{m.btn_edit()}
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Opens #781's create form pre-seeded with this person; on save it
|
||||||
|
returns to /persons/{id} (originPersonId). #842 REQ-003/010. -->
|
||||||
|
<a
|
||||||
|
data-testid="person-add-event"
|
||||||
|
href="/zeitstrahl/events/new?personId={person.id}"
|
||||||
|
class="mt-2 flex min-h-[44px] w-full items-center justify-center rounded border border-line px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.person_add_event()}
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
frontend/src/routes/persons/[id]/PersonCard.svelte.spec.ts
Normal file
32
frontend/src/routes/persons/[id]/PersonCard.svelte.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import PersonCard from './PersonCard.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const PERSON_ID = '55555555-5555-5555-5555-555555555555';
|
||||||
|
|
||||||
|
const makePerson = (overrides = {}) => ({
|
||||||
|
id: PERSON_ID,
|
||||||
|
firstName: 'Karl',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
displayName: 'Karl Raddatz',
|
||||||
|
personType: 'PERSON',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PersonCard add-event affordance (#842)', () => {
|
||||||
|
it('shows an add-event link pre-seeded with the person to a curator (REQ-003)', () => {
|
||||||
|
render(PersonCard, { person: makePerson(), canWrite: true });
|
||||||
|
const add = document.querySelector(
|
||||||
|
'[data-testid="person-add-event"]'
|
||||||
|
) as HTMLAnchorElement | null;
|
||||||
|
expect(add).not.toBeNull();
|
||||||
|
expect(add?.getAttribute('href')).toBe(`/zeitstrahl/events/new?personId=${PERSON_ID}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no add-event link to a reader (REQ-004)', () => {
|
||||||
|
render(PersonCard, { person: makePerson(), canWrite: false });
|
||||||
|
expect(document.querySelector('[data-testid="person-add-event"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
||||||
|
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -35,7 +36,14 @@ function otherId(rel: RelationshipDTO): string {
|
|||||||
{#if relationships.length > 0}
|
{#if relationships.length > 0}
|
||||||
<ul class="mb-4 space-y-2">
|
<ul class="mb-4 space-y-2">
|
||||||
{#each relationships as rel (rel.id)}
|
{#each relationships as rel (rel.id)}
|
||||||
<li class="flex items-center gap-2">
|
{@const dateRange = formatRelationshipDateRange(
|
||||||
|
rel.fromDate,
|
||||||
|
rel.fromDatePrecision,
|
||||||
|
rel.toDate,
|
||||||
|
rel.toDatePrecision
|
||||||
|
)}
|
||||||
|
<li class="flex flex-col gap-0.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||||
>
|
>
|
||||||
@@ -47,6 +55,18 @@ function otherId(rel: RelationshipDTO): string {
|
|||||||
>
|
>
|
||||||
{otherName(rel, personId)}
|
{otherName(rel, personId)}
|
||||||
</a>
|
</a>
|
||||||
|
{#if dateRange}
|
||||||
|
<span
|
||||||
|
class="shrink-0 font-sans text-xs text-ink-3"
|
||||||
|
data-testid="relationship-date-range">{dateRange}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if rel.notes}
|
||||||
|
<p class="pl-1 font-serif text-xs text-ink-2 italic" data-testid="relationship-notes">
|
||||||
|
{rel.notes}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: SPOUSE_ID,
|
relatedPersonId: SPOUSE_ID,
|
||||||
personDisplayName: 'Anna Müller',
|
personDisplayName: 'Anna Müller',
|
||||||
relatedPersonDisplayName: 'Bertha Müller',
|
relatedPersonDisplayName: 'Bertha Müller',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: [
|
inferredRelationships: [
|
||||||
@@ -65,7 +67,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: PARENT_ID,
|
relatedPersonId: PARENT_ID,
|
||||||
personDisplayName: 'Anna Müller',
|
personDisplayName: 'Anna Müller',
|
||||||
relatedPersonDisplayName: 'Kind Müller',
|
relatedPersonDisplayName: 'Kind Müller',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: []
|
inferredRelationships: []
|
||||||
@@ -84,7 +88,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: SPOUSE_ID,
|
relatedPersonId: SPOUSE_ID,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: [
|
inferredRelationships: [
|
||||||
@@ -113,7 +119,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: PERSON_ID,
|
relatedPersonId: PERSON_ID,
|
||||||
personDisplayName: 'Eltern Müller',
|
personDisplayName: 'Eltern Müller',
|
||||||
relatedPersonDisplayName: 'Anna Müller',
|
relatedPersonDisplayName: 'Anna Müller',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: []
|
inferredRelationships: []
|
||||||
@@ -121,4 +129,74 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
|
|
||||||
await expect.element(page.getByText('Kind von')).toBeInTheDocument();
|
await expect.element(page.getByText('Kind von')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the date range at its stored precision', async () => {
|
||||||
|
render(PersonRelationshipsCard, {
|
||||||
|
personId: PERSON_ID,
|
||||||
|
relationships: [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
personId: PERSON_ID,
|
||||||
|
relatedPersonId: SPOUSE_ID,
|
||||||
|
personDisplayName: 'Anna Müller',
|
||||||
|
relatedPersonDisplayName: 'Bertha Müller',
|
||||||
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDate: '1923-05-12',
|
||||||
|
fromDatePrecision: 'DAY',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
inferredRelationships: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByTestId('relationship-date-range'))
|
||||||
|
.toHaveTextContent('12. Mai 1923');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the notes line', async () => {
|
||||||
|
render(PersonRelationshipsCard, {
|
||||||
|
personId: PERSON_ID,
|
||||||
|
relationships: [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
personId: PERSON_ID,
|
||||||
|
relatedPersonId: SPOUSE_ID,
|
||||||
|
personDisplayName: 'Anna Müller',
|
||||||
|
relatedPersonDisplayName: 'Bertha Müller',
|
||||||
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN',
|
||||||
|
notes: 'Hochzeit in Berlin'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
inferredRelationships: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByTestId('relationship-notes'))
|
||||||
|
.toHaveTextContent('Hochzeit in Berlin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no date line when the relationship has no dates', async () => {
|
||||||
|
render(PersonRelationshipsCard, {
|
||||||
|
personId: PERSON_ID,
|
||||||
|
relationships: [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
personId: PERSON_ID,
|
||||||
|
relatedPersonId: SPOUSE_ID,
|
||||||
|
personDisplayName: 'Anna Müller',
|
||||||
|
relatedPersonDisplayName: 'Bertha Müller',
|
||||||
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
inferredRelationships: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Bertha Müller')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('[data-testid="relationship-date-range"]')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,40 @@ import { error, fail, redirect } from '@sveltejs/kit';
|
|||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
import {
|
import {
|
||||||
normalizePersonType,
|
normalizePersonType,
|
||||||
validatePersonFields,
|
validatePersonFields,
|
||||||
resolveValidationMessage
|
resolveValidationMessage
|
||||||
} from '$lib/person/person-validation';
|
} from '$lib/person/person-validation';
|
||||||
|
|
||||||
|
type RelationType = NonNullable<components['schemas']['RelationshipUpsertRequest']['relationType']>;
|
||||||
|
|
||||||
|
// Parses the shared relationship create/update form into a RelationshipUpsertRequest
|
||||||
|
// body. An empty date omits date AND precision so the backend normalises the pair to
|
||||||
|
// null/UNKNOWN — a lone precision would fail the coherence check (INVALID_DATE_PRECISION).
|
||||||
|
function parseRelationshipForm(formData: FormData) {
|
||||||
|
const relatedPersonId = formData.get('relatedPersonId')?.toString();
|
||||||
|
const relationType = formData.get('relationType')?.toString();
|
||||||
|
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||||
|
const fromDate = formData.get('fromDate')?.toString().trim() || undefined;
|
||||||
|
const fromDatePrecision = fromDate
|
||||||
|
? (formData.get('fromDatePrecision')?.toString() as DatePrecision)
|
||||||
|
: undefined;
|
||||||
|
const toDate = formData.get('toDate')?.toString().trim() || undefined;
|
||||||
|
const toDatePrecision = toDate
|
||||||
|
? (formData.get('toDatePrecision')?.toString() as DatePrecision)
|
||||||
|
: undefined;
|
||||||
|
const body = {
|
||||||
|
relatedPersonId: relatedPersonId ?? '',
|
||||||
|
relationType: (relationType ?? 'OTHER') as RelationType,
|
||||||
|
...(fromDate ? { fromDate, fromDatePrecision } : {}),
|
||||||
|
...(toDate ? { toDate, toDatePrecision } : {}),
|
||||||
|
...(notes ? { notes } : {})
|
||||||
|
};
|
||||||
|
return { relatedPersonId, relationType, body };
|
||||||
|
}
|
||||||
|
|
||||||
export async function load({ params, fetch, locals }) {
|
export async function load({ params, fetch, locals }) {
|
||||||
const canWrite =
|
const canWrite =
|
||||||
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||||
@@ -193,40 +221,45 @@ export const actions = {
|
|||||||
|
|
||||||
addRelationship: async ({ request, params, fetch }) => {
|
addRelationship: async ({ request, params, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const relatedPersonId = formData.get('relatedPersonId')?.toString();
|
const fields = parseRelationshipForm(formData);
|
||||||
const relationType = formData.get('relationType')?.toString();
|
|
||||||
const fromYearRaw = formData.get('fromYear')?.toString().trim();
|
|
||||||
const toYearRaw = formData.get('toYear')?.toString().trim();
|
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
|
||||||
|
|
||||||
if (!relatedPersonId || !relationType) {
|
if (!fields.relatedPersonId || !fields.relationType) {
|
||||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||||
}
|
}
|
||||||
if (relatedPersonId === params.id) {
|
if (fields.relatedPersonId === params.id) {
|
||||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
|
||||||
}
|
|
||||||
const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined;
|
|
||||||
const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined;
|
|
||||||
if (
|
|
||||||
fromYear !== undefined &&
|
|
||||||
toYear !== undefined &&
|
|
||||||
!Number.isNaN(fromYear) &&
|
|
||||||
!Number.isNaN(toYear) &&
|
|
||||||
toYear < fromYear
|
|
||||||
) {
|
|
||||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.POST('/api/persons/{id}/relationships', {
|
const result = await api.POST('/api/persons/{id}/relationships', {
|
||||||
params: { path: { id: params.id } },
|
params: { path: { id: params.id } },
|
||||||
body: {
|
body: fields.body
|
||||||
relatedPersonId,
|
});
|
||||||
relationType,
|
|
||||||
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}),
|
if (!result.response.ok) {
|
||||||
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}),
|
return fail(result.response.status, {
|
||||||
...(notes ? { notes } : {})
|
relationshipError: getErrorMessage(extractErrorCode(result.error))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return { relationshipSuccess: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRelationship: async ({ request, params, fetch }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const relId = formData.get('relId')?.toString();
|
||||||
|
const fields = parseRelationshipForm(formData);
|
||||||
|
|
||||||
|
if (!relId || !fields.relatedPersonId || !fields.relationType) {
|
||||||
|
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||||
|
}
|
||||||
|
if (fields.relatedPersonId === params.id) {
|
||||||
|
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.PUT('/api/persons/{id}/relationships/{relId}', {
|
||||||
|
params: { path: { id: params.id, relId } },
|
||||||
|
body: fields.body
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
|
|||||||
@@ -97,3 +97,98 @@ describe('persons/[id]/edit update action — generation (#689)', () => {
|
|||||||
expect(body).toHaveProperty('generation', 3);
|
expect(body).toHaveProperty('generation', 3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('persons/[id]/edit relationship actions (#837)', () => {
|
||||||
|
function relForm(overrides: Record<string, string | null> = {}): Request {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('relatedPersonId', 'p2');
|
||||||
|
fd.set('relationType', 'SPOUSE_OF');
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v == null) fd.delete(k);
|
||||||
|
else fd.set(k, v);
|
||||||
|
}
|
||||||
|
return new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('addRelationship posts date + precision + notes', async () => {
|
||||||
|
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
const request = relForm({
|
||||||
|
fromDate: '1923-05-12',
|
||||||
|
fromDatePrecision: 'DAY',
|
||||||
|
notes: 'Hochzeit'
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||||
|
|
||||||
|
const [path, opts] = post.mock.calls[0];
|
||||||
|
expect(path).toBe('/api/persons/{id}/relationships');
|
||||||
|
expect(opts.body).toMatchObject({
|
||||||
|
relatedPersonId: 'p2',
|
||||||
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDate: '1923-05-12',
|
||||||
|
fromDatePrecision: 'DAY',
|
||||||
|
notes: 'Hochzeit'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addRelationship omits precision when the date is empty (coherence)', async () => {
|
||||||
|
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
const request = relForm({ fromDatePrecision: 'DAY' }); // precision but no date
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||||
|
|
||||||
|
const body = post.mock.calls[0][1].body;
|
||||||
|
expect(body).not.toHaveProperty('fromDate');
|
||||||
|
expect(body).not.toHaveProperty('fromDatePrecision');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateRelationship PUTs to the relId path with the new body', async () => {
|
||||||
|
const put = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
const request = relForm({ relId: 'rel-9', fromDate: '1923-05-12', fromDatePrecision: 'DAY' });
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await actions.updateRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||||
|
|
||||||
|
const [path, opts] = put.mock.calls[0];
|
||||||
|
expect(path).toBe('/api/persons/{id}/relationships/{relId}');
|
||||||
|
expect(opts.params.path).toMatchObject({ id: 'p1', relId: 'rel-9' });
|
||||||
|
expect(opts.body).toMatchObject({
|
||||||
|
relatedPersonId: 'p2',
|
||||||
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDate: '1923-05-12',
|
||||||
|
fromDatePrecision: 'DAY'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateRelationship surfaces a backend error as a fail', async () => {
|
||||||
|
const put = vi.fn().mockResolvedValue({
|
||||||
|
response: { ok: false, status: 400 },
|
||||||
|
error: { code: 'INVALID_RELATIONSHIP_DATES' }
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
const request = relForm({ relId: 'rel-9' });
|
||||||
|
|
||||||
|
const result = (await actions.updateRelationship({
|
||||||
|
request,
|
||||||
|
params: { id: 'p1' },
|
||||||
|
fetch: mockFetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any)) as { status: number; data: { relationshipError: string } };
|
||||||
|
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.data.relationshipError).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,14 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
||||||
|
import TimelineFilters from '$lib/timeline/TimelineFilters.svelte';
|
||||||
|
import GroupingControl from '$lib/timeline/GroupingControl.svelte';
|
||||||
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
||||||
|
import { filterTimeline } from '$lib/timeline/timelineFilter';
|
||||||
|
import { hasLooseLetters, type GroupingMode } from '$lib/timeline/timelineGrouping';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const meta = $derived(timelineMeta(data.timeline));
|
|
||||||
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
||||||
|
|
||||||
|
// Layer-filter state (#780). Layer hiding is client-side only — the whole
|
||||||
|
// timeline is loaded once by #779's SSR load and we derive a filtered view of
|
||||||
|
// it here; there is no goto, no URL param, and no extra fetch.
|
||||||
|
let personalOn = $state(true);
|
||||||
|
let historicalOn = $state(true);
|
||||||
|
let lettersOn = $state(true);
|
||||||
|
|
||||||
|
// Grouping state (#827) lives here beside the layer-filter state; the regroup is a
|
||||||
|
// pure client-side transform over the already-filtered view — filter-then-group.
|
||||||
|
let groupingMode = $state<GroupingMode>('date');
|
||||||
|
|
||||||
|
const filteredTimeline = $derived(
|
||||||
|
filterTimeline(data.timeline, { personalOn, historicalOn, lettersOn })
|
||||||
|
);
|
||||||
|
const filteredEmpty = $derived(
|
||||||
|
filteredTimeline.years.length === 0 && filteredTimeline.undated.length === 0
|
||||||
|
);
|
||||||
|
// The grouping control is only meaningful while loose letters remain in the filtered
|
||||||
|
// view; with the Letters layer off there is nothing to regroup, so it disables but
|
||||||
|
// keeps its selected mode (REQ-018).
|
||||||
|
const hasLetters = $derived(hasLooseLetters(filteredTimeline));
|
||||||
|
|
||||||
|
// Meta-line figures track the *filtered* view, so the header counts always
|
||||||
|
// match what is actually on screen once layers are toggled off (#780 — this
|
||||||
|
// closes the prior D1 limitation, where the counts stayed on the full timeline).
|
||||||
|
const meta = $derived(timelineMeta(filteredTimeline));
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
personalOn = true;
|
||||||
|
historicalOn = true;
|
||||||
|
lettersOn = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Compose the sub-line from segments joined by " · " so the range drops out
|
// Compose the sub-line from segments joined by " · " so the range drops out
|
||||||
// cleanly when there are no year bands; the whole line is absent when the
|
// cleanly when there are no year bands; the whole line is absent when the
|
||||||
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
||||||
@@ -34,7 +70,13 @@ const metaLine = $derived.by(() => {
|
|||||||
: m.timeline_events_count({ count: meta.eventCount })
|
: m.timeline_events_count({ count: meta.eventCount })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
segments.push(m.timeline_grouping_date());
|
segments.push(
|
||||||
|
groupingMode === 'event'
|
||||||
|
? m.timeline_grouping_event()
|
||||||
|
: groupingMode === 'thema'
|
||||||
|
? m.timeline_grouping_thema()
|
||||||
|
: m.timeline_grouping_date()
|
||||||
|
);
|
||||||
return segments.join(' · ');
|
return segments.join(' · ');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -48,10 +90,56 @@ const metaLine = $derived.by(() => {
|
|||||||
border is intentionally omitted (the page is already bg-canvas), per the
|
border is intentionally omitted (the page is already bg-canvas), per the
|
||||||
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
|
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
|
||||||
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
|
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
|
||||||
|
<!-- Wrapping header so the CTA drops below the heading at narrow widths
|
||||||
|
(≤360px) instead of overflowing — #842 REQ-001. -->
|
||||||
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||||||
{#if hasContent}
|
{#if data.canWrite}
|
||||||
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
|
<a
|
||||||
|
data-testid="timeline-add-event"
|
||||||
|
href="/zeitstrahl/events/new"
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.timeline_add_event()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
{#if hasContent}
|
||||||
|
<p data-testid="timeline-meta" class="mt-1 mb-3 font-sans text-xs text-ink-3">{metaLine}</p>
|
||||||
|
<!-- Grouping toggle stacked above the #780 layer-filter trigger so the two read as
|
||||||
|
one control cluster in the header (REQ-010); the top-right corner stays the
|
||||||
|
add-event CTA. Disabled — but kept in place — when no loose letters remain
|
||||||
|
(REQ-018). -->
|
||||||
|
<div class="mb-3" data-testid="grouping-cluster">
|
||||||
|
<GroupingControl bind:mode={groupingMode} disabled={!hasLetters} />
|
||||||
|
</div>
|
||||||
|
<TimelineFilters
|
||||||
|
bind:personalOn={personalOn}
|
||||||
|
bind:historicalOn={historicalOn}
|
||||||
|
bind:lettersOn={lettersOn}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hasContent && filteredEmpty}
|
||||||
|
<!-- Filtered-empty: a calm message + one-click reset below the still-open
|
||||||
|
filter bar — never a blank page, and never the generic "no events"
|
||||||
|
state (which would imply the archive itself is empty). REQ-006. -->
|
||||||
|
<div data-testid="timeline-filter-empty" class="py-12 text-center">
|
||||||
|
<p class="font-serif text-base text-ink-2">{m.timeline_filter_empty_state()}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="timeline-filter-empty-reset"
|
||||||
|
onclick={resetFilters}
|
||||||
|
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{m.timeline_filter_reset()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TimelineView
|
||||||
|
timeline={filteredTimeline}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
groupingMode={groupingMode}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<TimelineView timeline={data.timeline} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
|
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
|
||||||
@@ -111,3 +112,214 @@ describe('/zeitstrahl page', () => {
|
|||||||
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
|
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/zeitstrahl layer filter (#780)', () => {
|
||||||
|
const letter = (title: string, documentId: string) => makeEntry({ documentId, title });
|
||||||
|
const historical = (title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'h1',
|
||||||
|
documentId: undefined,
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: ''
|
||||||
|
});
|
||||||
|
const personal = (title: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'p1',
|
||||||
|
documentId: undefined,
|
||||||
|
title,
|
||||||
|
senderName: '',
|
||||||
|
receiverName: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixed = () =>
|
||||||
|
makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [
|
||||||
|
letter('Brief Eins', 'd1'),
|
||||||
|
historical('Erster Weltkrieg'),
|
||||||
|
personal('Umzug nach Berlin')
|
||||||
|
])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openBar() {
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hides letter cards when the Letters layer is off and restores them, with no fetch (REQ-005/002)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.poll(() => page.getByText('Brief Eins').query()).toBeNull();
|
||||||
|
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides historical event cards when the Historical layer is off (REQ-004)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-historical').click();
|
||||||
|
await expect.poll(() => page.getByText('Erster Weltkrieg').query()).toBeNull();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Umzug nach Berlin')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides personal event cards when the Personal layer is off (REQ-003)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-personal').click();
|
||||||
|
await expect.poll(() => page.getByText('Umzug nach Berlin').query()).toBeNull();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Erster Weltkrieg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the filtered-empty message + reset below the open bar when all layers are off (REQ-006)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-personal').click();
|
||||||
|
await page.getByTestId('timeline-filter-historical').click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(page.getByText(m.timeline_filter_empty_state())).toBeVisible();
|
||||||
|
await expect.element(page.getByTestId('timeline-filter-empty-reset')).toBeVisible();
|
||||||
|
// the generic TimelineView empty state is never what shows for a filtered-empty view
|
||||||
|
expect(page.getByText(m.timeline_empty_state()).query()).toBeNull();
|
||||||
|
// the one-click reset restores every layer
|
||||||
|
await page.getByTestId('timeline-filter-empty-reset').click();
|
||||||
|
await expect.element(page.getByText('Brief Eins')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes the meta-line counts from the filtered view, so a hidden layer drops out of the totals (#780, resolves D1)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
const meta = page.getByTestId('timeline-meta');
|
||||||
|
// all layers on → the one letter and the two events are counted
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_letters_count_singular());
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 }));
|
||||||
|
|
||||||
|
await openBar();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
|
||||||
|
// the hidden letter leaves the count instead of lingering as "1 Brief";
|
||||||
|
// the event total is untouched
|
||||||
|
await expect.element(meta).not.toHaveTextContent(m.timeline_letters_count_singular());
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_events_count({ count: 2 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/zeitstrahl curator affordances (#842)', () => {
|
||||||
|
const curated = (eventId: string) =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId,
|
||||||
|
title: 'Auswanderung',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const withWrite = (timeline: ReturnType<typeof makeTimelineDTO>) => ({
|
||||||
|
...pageData(timeline),
|
||||||
|
canWrite: true
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the add-event CTA in a wrapping header when the viewer can write (REQ-001)', () => {
|
||||||
|
render(Page, { data: withWrite(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] })) });
|
||||||
|
const add = document.querySelector(
|
||||||
|
'[data-testid="timeline-add-event"]'
|
||||||
|
) as HTMLAnchorElement | null;
|
||||||
|
expect(add).not.toBeNull();
|
||||||
|
expect(add?.getAttribute('href')).toBe('/zeitstrahl/events/new');
|
||||||
|
// The header wraps so the CTA drops below the heading at narrow widths (≤360px)
|
||||||
|
// rather than overflowing — REQ-001.
|
||||||
|
expect(add?.closest('header')?.classList.contains('flex-wrap')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no add-event CTA when the viewer cannot write (REQ-002)', () => {
|
||||||
|
render(Page, {
|
||||||
|
data: pageData(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] }))
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="timeline-add-event"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads canWrite to the timeline so a curator sees an event edit link (REQ-001/009)', () => {
|
||||||
|
render(Page, {
|
||||||
|
data: withWrite(makeTimelineDTO({ years: [makeYear(1924, [curated('p9')])] }))
|
||||||
|
});
|
||||||
|
expect(document.querySelector('a[href="/zeitstrahl/events/p9/edit"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no event edit link to a reader (REQ-007)', () => {
|
||||||
|
render(Page, {
|
||||||
|
data: pageData(makeTimelineDTO({ years: [makeYear(1924, [curated('p9')])] }))
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/zeitstrahl grouping toggle (#827)', () => {
|
||||||
|
const historical = () =>
|
||||||
|
makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'HISTORICAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'h1',
|
||||||
|
documentId: undefined,
|
||||||
|
title: 'Erster Weltkrieg',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: ''
|
||||||
|
});
|
||||||
|
const mixed = () =>
|
||||||
|
makeTimelineDTO({
|
||||||
|
years: [
|
||||||
|
makeYear(1915, [
|
||||||
|
makeEntry({ documentId: 'd1', title: 'Brief Eins', linkedEventId: 'h1' }),
|
||||||
|
historical()
|
||||||
|
])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const radio = (value: string) => document.querySelector(`[data-value="${value}"]`) as HTMLElement;
|
||||||
|
|
||||||
|
it('updates the meta-line grouping label when a mode is chosen (REQ-016)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
const meta = page.getByTestId('timeline-meta');
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_grouping_date());
|
||||||
|
radio('event').click();
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_grouping_event());
|
||||||
|
radio('thema').click();
|
||||||
|
await expect.element(meta).toHaveTextContent(m.timeline_grouping_thema());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regroups loose letters under their event client-side, no buckets in Datum (REQ-002/003)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
|
||||||
|
radio('event').click();
|
||||||
|
await expect.element(page.getByTestId('letter-bucket')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the grouping control when the Letters layer is off, keeping the mode (REQ-018)', async () => {
|
||||||
|
render(Page, { data: pageData(mixed()) });
|
||||||
|
radio('thema').click();
|
||||||
|
const control = page.getByTestId('grouping-control');
|
||||||
|
await expect.element(control).toHaveAttribute('aria-disabled', 'false');
|
||||||
|
// turn the Letters layer off → nothing to regroup
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(control).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
// the chosen mode is retained for when letters return
|
||||||
|
expect(radio('thema').getAttribute('aria-checked')).toBe('true');
|
||||||
|
// re-enabling restores the enabled control with the same mode (no reset to Datum)
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
await expect.element(control).toHaveAttribute('aria-disabled', 'false');
|
||||||
|
expect(radio('thema').getAttribute('aria-checked')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user