Compare commits

...

91 Commits

Author SHA1 Message Date
Marcel
a2c633c5de feat(transcription): findByMentionedPersons_PersonId derived query
Spring Data resolves the method name to a join over
transcription_block_mentioned_persons, returning every block whose sidecar
contains the given personId. The B-tree index on person_id (V56) keeps the
lookup O(log n) — required for the rename propagation that fans out to
every block referencing the renamed person, and for the future
"show all blocks mentioning person X" query on the person detail page.

The underscore between MentionedPersons and PersonId is the explicit
property-boundary form, immune to ambiguous longest-match parsing if the
embeddable later gains another nested object.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:21:23 +02:00
Marcel
28112e1d7b test(person): alias-only and notes-only updates do not publish display-name event
Two regression guards on the "iff different" semantics in updatePerson.
Person.alias and Person.notes are not part of getDisplayName() — they live
outside DisplayNameFormatter — so changing only those fields must not fire
PersonDisplayNameChangedEvent. If a future refactor accidentally pulls
either field into the display name (or trips the comparison), these tests
catch it before transcription blocks get rewritten with stale "@OldAlias"
text.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:18:35 +02:00
Marcel
08e7987033 feat(person): updatePerson publishes PersonDisplayNameChangedEvent on display-name change
PersonService now emits a domain event whenever Person.getDisplayName()
flips during an update. The snapshot is taken before the setter chain so we
compare like-for-like against the post-save value, and the event only
publishes when the two strings differ.

The test captures the published event via ArgumentCaptor and asserts the
title flip from "Herr" to "Frau" reaches the publisher with the correct
personId, oldDisplayName, and newDisplayName. Title participates in
DisplayNameFormatter, so this is the canonical case for "rename triggered
by something other than first/last name."

Implements PR-A tasks 9 and 10 as one red→green cycle (the test drove the
production change). Subsequent commits cover the negative cases (alias /
notes only) and the propagation listener that consumes the event.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:17:17 +02:00
Marcel
1db0f38f62 test(transcription): 400 + VALIDATION_ERROR when mention personId is null
Regression guard for the @NotNull on PersonMention.personId paired with
@Valid on the DTO field. The wiring was added in the previous commit; this
test ensures dropping either annotation in the future causes a loud test
failure rather than silently allowing payloads with no personId to reach
the service layer (where the listener relies on the UUID being present).

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:14:14 +02:00
Marcel
4e8df66a79 test(transcription): 400 + VALIDATION_ERROR when mention displayName exceeds 200 chars
Wires @Valid on the @RequestBody parameter of TranscriptionBlockController's
createBlock and updateBlock methods so JSR-303 actually fires for incoming
DTOs. With @Valid on the field-level mentionedPersons in the DTO (added in
the previous commit), Jakarta validation now recurses into each
PersonMention element and rejects displayName values past the @Size(max=200)
ceiling.

The test posts a 201-char displayName and asserts the global handler maps
the resulting MethodArgumentNotValidException to 400 + code:VALIDATION_ERROR.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:12:53 +02:00
Marcel
80ddfb47ac feat(transcription): DTOs accept mentionedPersons sidecar with @Valid cascade
CreateTranscriptionBlockDTO and UpdateTranscriptionBlockDTO gain a
List<PersonMention> mentionedPersons field. @Valid is on the field itself,
not just on the controller method, so JSR-303 recurses into the list
elements when the controller boundary calls @Valid on the @RequestBody. The
collection defaults to an empty ArrayList via @Builder.Default; existing
constructor call sites in TranscriptionServiceTest are extended with
List.of() to match the new @AllArgsConstructor signature.

The controller-side @Valid wiring lands in the next commit alongside the
length-201 validation test.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:11:01 +02:00
Marcel
7805da52e6 test(transcription): round-trip TranscriptionBlock.mentionedPersons
@DataJpaTest + Testcontainers exercises the V56 migration plus the
@ElementCollection wiring end-to-end. Saves a block with two PersonMention
entries, clears the persistence context, reloads, asserts both entries
return with their personId + displayName intact. Second test guards the
@Builder.Default — a block without explicit mentions reloads with an empty
list, not null.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:07:56 +02:00
Marcel
0f3e000379 feat(transcription): TranscriptionBlock.mentionedPersons sidecar field
@ElementCollection(LAZY) on List<PersonMention>, mapped to V56's
transcription_block_mentioned_persons via explicit @CollectionTable that
matches the migration name byte-for-byte (immune to Hibernate naming-strategy
changes). @Builder.Default keeps the field initialized to an empty list, so
existing transcription block construction stays untouched.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:06:58 +02:00
Marcel
b435fd69f7 feat(person): PersonDisplayNameChangedEvent record
Carries personId + oldDisplayName + newDisplayName so transcription-side
listeners can rewrite block.text and sidecar entries when a person is
renamed. First custom application event in this codebase — the only prior
@EventListener consumes Spring's built-in ApplicationReadyEvent. Class doc
sets the convention for future cross-domain decoupling.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:05:39 +02:00
Marcel
a6c8db226d feat(transcription): PersonMention @Embeddable for sidecar entries
Value object held in TranscriptionBlock.mentionedPersons via @ElementCollection.
Carries the personId UUID (so renamed persons can be located) and the
displayName text (so block.text rewrites match exactly via "@" + name). Both
fields are non-null; displayName capped at 200 chars to match the V56 column
and bound the rename propagation cost.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:04:41 +02:00
Marcel
e833d1f71a feat(transcription): V56 migration adds transcription_block_mentioned_persons sidecar
Child table for @-mentions inside transcription block text. Each row binds
one block to one person via personId + displayName; the literal "@DisplayName"
stays in block.text. No FK on person_id so deleted persons degrade gracefully
to plain unlinked text rather than cascade-deleting the block. Indexed on
person_id for the future "blocks mentioning person X" query and on block_id
for the @ElementCollection load.

Schema choice diverges from document_comments.comment_mentions (many-to-many
to AppUser): the latter cascades, this one degrades. Mirrors the established
UserGroup.permissions / group_permissions @ElementCollection pattern.

Refs #362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 20:03:36 +02:00
Marcel
5d82a3e471 refactor(relationship): use typed RelationType enum in CreateRelationshipRequest
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m2s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m6s
Spring deserializes the enum directly; invalid values are caught by the
HttpMessageNotReadableException → 400 handler added in 99d00537, returning
a structured VALIDATION_ERROR. The manual parseType() helper is therefore
redundant and removed. Tests updated to construct requests with the enum.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:56:55 +02:00
Marcel
cb93f55396 refactor(stammbaum): StammbaumSidePanel composes AddRelationshipForm — removes inline form duplication
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m6s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
CI / Unit & Component Tests (push) Failing after 3m2s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Has been cancelled
Replaces the 86-line duplicated inline add-relationship form with
<AddRelationshipForm onSubmit={handleAddRelationship}>. The {#key node.id}
wrapper resets the form's open state when the selected tree node changes.
Year inputs now have <label> elements (WCAG 1.3.1) via the shared component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
3cfaae06da feat(stammbaum): AddRelationshipForm accepts onSubmit callback prop for fetch-based submission
When onSubmit is provided the form has no server action and calls the
callback with typed RelFormData instead. Uses a shared {#snippet} for
the form body so the two submission paths share one template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
a81323a7a1 fix(stammbaum): handle HttpMessageNotReadableException → 400 for invalid enum values
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
10b1bab57b fix(stammbaum): state-aware aria-label on family-member toggle — WCAG accessible name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
000333d540 fix(stammbaum): WCAG text-[10px] → text-xs in PersonRelationshipsCard chip labels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
5817a79151 test(stammbaum): year-range validation test for AddRelationshipForm — toYear before fromYear shows alert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
3b430828b7 test(stammbaum): component tests for StammbaumCard — heading, empty state, toggle, error display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
f8aa8c6574 test(stammbaum): component tests for StammbaumSidePanel — displayName, empty state, loading indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
ce005622f2 fix(stammbaum): i18n inline add-form in StammbaumSidePanel — replace 5 hardcoded German strings with m.relation_form_*() keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
0e9fa157e5 fix(stammbaum): add × dismiss button with aria-label to StammbaumSidePanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
fa1dfbc99d fix(stammbaum): guard inferred-relationship badge to single-receiver documents only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
eb91639a5e fix(stammbaum): responsive /stammbaum layout — hidden md:block aside + fixed bottom sheet on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
43fb51305e fix(stammbaum): i18n AddRelationshipForm — wire Paraglide for type/year labels and optgroup captions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
6babcc7f17 fix(stammbaum): V55 adds unique_spouse_pair index — symmetric SPOUSE_OF enforced at DB level
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
1754b96b18 test(stammbaum): happy-path controller tests for GET /api/network, GET inferred-rels, POST+DELETE relationships
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
d230156651 test(stammbaum): getFamilyNetwork excludes edges with non-family endpoints
Proves the in-memory filter correctly drops edges where one Person is
not in findAllFamilyMembers(), preventing non-family relationships from
leaking into the graph.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
93f4a00032 fix(stammbaum): SVG node font 14→16px and reliable keyboard focus ring
CSS box-shadow rings (focus-visible:ring-*) are invisible inside SVG.
Replace with a conditional <rect> drawn at -3px offset that renders in
all browsers. Name font-size bumped from 14 to 16px for the 60+
transcriber audience (WCAG readability, Leonie medium concerns).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
ea97bdd869 refactor(stammbaum): initialise selectedId directly from focusId, drop $effect
The focus deep-link is a one-time load param — $derived + $effect caused
a deferred write that left the node unselected on first paint. Initialising
$state inline reads the URL once at component mount with no reactive cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
cbaff016d0 docs(stammbaum): explain MAX_DEPTH=8 rationale on RelationshipInferenceService
8 hops covers great-grandparents ↔ great-great-grandchildren and second
cousins — the practical horizon for a 1899–1950 archive. Prevents future
blind tuning of the constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
0b3455dbb2 test(stammbaum): skip E2E spec until CI Playwright job exists (#363)
All four tests skipped with a reference to issue #363 which tracks
adding the Playwright Chromium install + Docker Compose startup step
to the CI workflow. Remove the skip once #363 is resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
499d0a3ca8 fix(stammbaum): derived relationship names link to person page in StammbaumCard
The <span> in the derived-relationships list is replaced with <a href>
so keyboard and pointer users can navigate directly from the edit card,
consistent with PersonRelationshipsCard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
bd3feda182 fix(stammbaum): WCAG 2.2 SC 2.5.8 — delete button 32px → 44px in RelationshipChip
h-8 w-8 (32px) replaced with h-11 w-11 (44px) to meet the minimum
touch target for the 60+ transcriber audience. Test added to prevent
regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
f2127e2814 fix(stammbaum): i18n the StammbaumCard heading (de/en/es)
Hardcoded 'Stammbaum & Beziehungen' heading replaced with
m.stammbaum_relationships_heading(); new key added to all
three message files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
13bb3b451e fix(stammbaum): import chipLabel/otherName from shared relationshipLabels in PersonRelationshipsCard
Removes local duplicates of the switch-statement label logic already
exported from $lib/relationshipLabels.ts. Adds two direction-sensitive
tests proving the Elternteil-von / Kind-von branch is covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
6074ac396f docs(stammbaum): document intentional auth design on RelationshipController GET endpoints
Addresses @markus/@nora suggestion: makes explicit that the missing
@RequirePermission on read endpoints is intentional — all authenticated
family members may read the family graph; unauthenticated access is still
blocked by Spring Security's anyRequest().authenticated() rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
b6253cb023 fix(stammbaum): add focus-visible ring to zoom buttons — WCAG 2.4.7
Addresses @leonie blocker: zoom buttons in /stammbaum had no visible focus
indicator for keyboard users. Applied focus-visible:ring-2 focus-visible:ring-focus-ring
focus-visible:outline-none matching the pattern used on nav links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
e94e9a3573 test(stammbaum): prove DELETE and PATCH /family-member return 403 for READ_ALL-only users
Addresses @sara blocker: RelationshipControllerTest now has 6 tests covering
the two previously untested @RequirePermission(WRITE_ALL) endpoints. Prevents
silent permission regression if the controller is refactored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
06ecad5e74 test(stammbaum): prove GET /api/network and GET /api/persons/{id}/relationships reject unauthenticated requests (401)
Addresses @sara blocker: documents that Spring Security's anyRequest().authenticated()
guards these read endpoints and provides regression protection against accidental
@PermitAll additions in future.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
fcfae8fb78 refactor(stammbaum): use shared chipLabel/otherName from relationshipLabels in both components
Addresses @felix blocker: removes the verbatim duplicate switch+2-line helper
from StammbaumCard.svelte and StammbaumSidePanel.svelte; both now import from
the shared $lib/relationshipLabels helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
83de7ff673 refactor(stammbaum): extract chipLabel/otherName to shared relationshipLabels helper
Addresses @felix blocker: both functions were duplicated verbatim in
StammbaumCard.svelte and StammbaumSidePanel.svelte. Now exported from
$lib/relationshipLabels.ts with perspectivePersonId as an explicit param.
8 unit tests added (red→green).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
48649e67f9 refactor(stammbaum): extract RelationshipChip and AddRelationshipForm
Split StammbaumCard from 366 to 196 lines by extracting:
- RelationshipChip.svelte — single relationship list item with optional delete
- AddRelationshipForm.svelte — self-contained add-relationship form with open/close state

Both components have browser-mode spec tests covering rendering and interaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
1d14c32c23 fix(stammbaum): WCAG min font-size and 44px touch targets
Raise chip labels from 10px to 12px (text-xs) in StammbaumCard,
StammbaumSidePanel and StammbaumTree SVG text. Widen zoom buttons
from 32px to 44px for senior-audience touch targets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
d27fed3c35 fix(stammbaum): i18n for year-range labels in StammbaumCard
Replaces hardcoded German strings "ab {from}" / "bis {to}" in yearRange()
with parameterized Paraglide keys relation_year_from / relation_year_to,
added to all three message files (de/en/es).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
22752ac1ae fix(stammbaum): structured error codes in RelationshipController
getRelationshipBetween now throws DomainException with RELATIONSHIP_NOT_FOUND
instead of ResponseStatusException, so the frontend receives a typed error code.
Removed redundant validateRelationType() guard — RelationshipService.parseType()
already handles this with the same DomainException/VALIDATION_ERROR path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
7a3d919c2d fix(stammbaum): resolve persons via PersonService in RelationshipInferenceService
Removes direct PersonRepository injection from the relationship domain,
routing cross-domain person resolution through PersonService.getAllById()
per the layering rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
b969bcd877 style(stammbaum): widen side panel to 320px so longer names don't clip
The 268px width came from the spec mock; real names plus the
relationship pill ("Eugenie de Gruyter" + "Elternteil") need more
breathing room.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
cd26057ea5 fix(stammbaum): iterative generation + spouse-adjacent block layout
Two distinct bugs surfaced once a 3-generation tree was loaded
(Walter+Eugenie → Hans+Clara, Hans married to Hilde with child Lili):

1. Generation BFS was non-iterative. Hilde was visited as a "root"
   first, assigning Lili = gen 1, then Hilde was pulled to gen 1 to
   match her spouse Hans — but Lili's depth was never recomputed,
   leaving her on the same row as her parents. Replaced the BFS with
   an iterative longest-path assignment that re-runs (max parent gen
   + 1) and the spouse-shared-row rule together until stable.

2. No spouse adjacency. Hilde (no parents in the graph) ended up in
   her own block on the far left, with Hans + Clara to her right and
   the spouse line drawn straight across Clara's box. Replaced the
   per-parent-set grouping with a block model:
     - sibling-blocks group children of the same parent set
     - loose spouses attach on the outer edge of their partner's block
     - dual-loose spouse pairs merge into one 2-person block
     - each block is centred so its parented members' average sits
       exactly under the parent midpoint, keeping all connectors at 90°

Adds a regression test for the full Walter/Eugenie/Hans/Clara/Hilde/
Lili scenario (Lili in a deeper row, Hans+Hilde adjacent, no slanted
segments) and rewrites the viewBox tests to be position-agnostic via
a rect-centroid helper that reads the per-node `<g transform>`.

Tracked the eventual move to dagre (multi-marriage / cross-cousin /
~50+ nodes) in #361.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
ccbcbca0e8 feat(stammbaum): inline add-relationship form in side panel
Implements the inline-edit affordance from
docs/specs/stammbaum-tree-spec.html (section 3): a low-opacity
"+ Beziehung hinzufügen" button below the direct relationships list
expands into a compact form (type select, person typeahead,
optional Von/Bis Jahr inputs, Abbrechen + Speichern). On save the
form POSTs to /api/persons/{id}/relationships, reloads the panel's
own data, and calls invalidateAll() so the tree picks up the new
edge without a hard refresh.

The panel takes a new canWrite prop, plumbed through from the
+layout.server.ts data already exposed on page.data.

Also pins the /stammbaum canvas to the viewport (-my-6 cancels
<main>'s py-6, h-[calc(100dvh-4.25rem)] subtracts the navbar) so
the page no longer overflows below the fold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
c40cc05f68 feat(stammbaum): tree visual polish + parent-midpoint layout
Aligns the SVG tree with docs/specs/stammbaum-tree-spec.html:

- Node outline: var(--c-primary) at stroke-width=1.5 (was the much
  paler --c-line at 1) and selected text uses var(--c-primary-fg)
  so it remains readable on the dark/light primary fill
- Spouse line and parent-child line now share the same stroke style;
  spouse keeps the midpoint dot (radius bumped to 4.5 per spec)
- When two parents are connected by SPOUSE_OF, draw a single shared
  parent-pair → child line from the spouse midpoint instead of two
  diverging lines
- ViewBox: enforces a 1200×800 minimum and centers the content so a
  single node no longer scales up to fill the whole canvas in the
  top-left
- Children are positioned at the average of their parents' x and
  packed left-to-right per row, keeping connectors close to vertical

Adds component tests for the centring, the shared parent-pair link
(verified vertical), and the fallback to two lines when parents are
not spouses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
a021355072 feat(documents): inline relationship pills next to person names
Replaces the standalone "Beziehung" badge at the bottom of the
metadata drawer's Personen column with small inline pills attached
to each personCard — sender gets labelFromA, the single receiver
gets labelFromB. Matches docs/specs/stammbaum-doc-badge-spec.html.

Drops the now-unused RelationshipBadge component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
8971fee75e style(stammbaum): tighten vertical rhythm around relationship cards
- /stammbaum: drop the global py-6 top gap so the page header butts
  up against the navbar, matching its full-bleed canvas layout
- person detail: add mt-6 around the document lists so they don't
  sit flush against the Beziehungen card
- person edit: add mt-6 to PersonMergePanel so the merge box doesn't
  collide with the StammbaumCard above it

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
48a704f002 fix(stammbaum): drop inferred relationships that are already direct
A spouse listed as a direct PersonRelationship was also being
emitted as an inferred SPOUSE chip below, so the same person
appeared twice in the Beziehungen card.

Filter the inferred list against the IDs already shown as direct
edges before slicing the top 5. Added a component test that
renders red without the filter and green with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
a7b1dcb5e1 fix(stammbaum): JOIN FETCH persons in relationship queries
Both /api/network and /api/persons/{id}/relationships threw
LazyInitializationException when toDTO read Person.getDisplayName():
the read-side service methods aren't @Transactional, so the session
closed before the proxy could initialize.

Eagerly fetch r.person and r.relatedPerson in the two queries used
by these endpoints, keeping the no-@Transactional convention for
read methods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
f382bd9974 test(stammbaum): E2E spec + extend person load mock
- frontend/e2e/stammbaum.spec.ts covers four journeys:
  1) /briefwechsel still resolves with a 2xx after the nav swap.
  2) /stammbaum shows the page heading.
  3) /stammbaum renders either the empty state (with the Personenliste
     link) or at least one node[role=button] in the SVG.
  4) The person edit card surfaces the year-range error when Bis < Von.

- persons/[id]/page.server.spec.ts gains two extra mockResolvedValueOnce
  entries per scenario to match the new relationships +
  inferred-relationships GETs that the page load now performs.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
d7f4f6f163 feat(stammbaum): person detail Beziehungen card
- persons/[id]/+page.server.ts loads relationships and
  inferred-relationships in the existing parallel fetch.
- New PersonRelationshipsCard renders direct chips (mint) and the
  top-5 derived chips (grey) on /persons/{id}, both linked to the
  other person's page. Empty state shows
  "Noch keine Beziehungen bekannt." in muted serif.
- Card sits in the right column above the document lists.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
242e10179d feat(stammbaum): /stammbaum page — SVG tree + side panel + empty state
- /stammbaum/+page.server.ts loads GET /api/network (already filtered
  to family members on the backend) and returns nodes + edges.
- +page.svelte holds the page shell, manages selectedId (with
  ?focus={id} deep-link support) and zoom state, renders the empty
  state when nodes.length === 0 (icon + heading + body + link to
  /persons), or the tree + side panel otherwise.
- StammbaumTree.svelte: BFS-based generation assignment from roots,
  spouses promoted to the deeper generation so couples sit on the same
  row, alphabetical sort within row, simple grid layout. SVG nodes are
  role="button" + aria-label="{name}, {birth}–{death}" +
  aria-expanded={selected}, with click + Enter/Space activation. Solid
  parent→child connectors; mint spouse line with midpoint circle, dashed
  if SPOUSE_OF.toYear is set (former spouse). Zoom maps to viewBox.
- StammbaumSidePanel.svelte: lazily loads
  /api/persons/{id}/relationships and /inferred-relationships when the
  selection changes; shows direct chips (mint), top-5 derived chips
  (grey), and a "Zur Personenseite →" link. Escape closes the panel.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
aaf885cafd feat(stammbaum): person edit Stammbaum & Beziehungen card
New StammbaumCard rendered below the Namensverlauf card on
/persons/{id}/edit:
- Header with "Als Familienmitglied" toggle (form action
  toggleFamilyMember → PATCH /api/persons/{id}/family-member).
- "Erscheint im Stammbaum" banner with deep-link to
  /stammbaum?focus={id} when familyMember is true.
- Direct relationships list grouped by type, then year. Chip text is
  direction-aware: storage subject reads "Elternteil von", storage
  object reads "Kind von" (new relation_child_of i18n key in all 3
  locales). Symmetric and non-family types use their own keys.
- + Beziehung hinzufügen reveals an inline form with type select
  (grouped Familie / Sozial), a PersonTypeahead with the new
  excludePersonId prop (self-rel prevention, Elicit blocker 1), and
  Von / Bis year fields.
- Year validation lives client-side via $derived: empty/empty is OK,
  Bis < Von shows a red text-red-700 error wired with aria-describedby
  and disables submit (Sara blocker 3).
- Self-rel inline error mirrors the typeahead exclusion in case the
  user submits the personId regardless.
- Abgeleitete Beziehungen section (top 5) collapsed by default.

+page.server.ts loads relationships + inferred relationships in the
existing parallel fetch and adds three actions: toggleFamilyMember,
addRelationship (with year-range guard), deleteRelationship.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
b658a13247 feat(stammbaum): show inferred relationship in the document drawer
- New presentational RelationshipBadge component (labelFromA → arrow →
  labelFromB) wired into DocumentMetadataDrawer's Personen column,
  rendered after the receivers block when both endpoints are family
  members.
- DocumentTopBar gains an optional inferredRelationship prop and
  passes it through.
- documents/[id]/+page.server.ts loads the badge: only when sender is
  a family member, exactly one receiver, and that receiver is also a
  family member; 404 (no path) → null.
- relationshipLabels.ts maps the backend label keys (parent/child/...)
  to localised strings, so the server load returns badge-ready strings.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
6bed617959 feat(stammbaum): swap nav slot from /briefwechsel to /stammbaum
Both desktop and mobile nav rows now point at /stammbaum and read
m.nav_stammbaum(). The /briefwechsel route stays intact — only the
nav anchor changes.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
51db976348 feat(stammbaum): add i18n keys (de/en/es) + mirror error codes
In each of de/en/es:
- nav_stammbaum
- 9 relation_<type>_of keys for the stored relation types
- 17 relation_inferred_<label> keys covering everything LABEL_MAP emits
  (parent/child/spouse/sibling, grand*, great-grand*, uncle/aunt,
  niece/nephew, in-laws, cousin, distant)
- doc_details_field_relationship — badge label "Verwandtschaft"
- stammbaum_empty_*, stammbaum_panel_*, stammbaum_zoom_*,
  stammbaum_generations
- relation_error_* (inline form errors), relation_year_error_*,
  relation_label_*, relation_btn_*
- person_relationships_heading + person_relationships_empty
- error_relationship_not_found / error_circular_relationship /
  error_duplicate_relationship for the centralised error mapper

frontend/src/lib/errors.ts mirrors the backend's three new ErrorCodes
and routes them through getErrorMessage().

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
fc46704144 chore(stammbaum): regenerate TS API types for relationship endpoints
openapi-typescript pulled the Stammbaum schemas: Person now has
familyMember (required), plus PersonNodeDTO, NetworkDTO, RelationshipDTO,
InferredRelationshipDTO, InferredRelationshipWithPersonDTO,
CreateRelationshipRequest, FamilyMemberPatchDTO. Routes:
/api/network, /api/persons/{id}/relationships,
/api/persons/{id}/inferred-relationships,
/api/persons/{aId}/relationship-to/{bId}, and the family-member PATCH.

Test fixtures in PersonMultiSelect, briefwechsel page, and DocumentList
specs gained familyMember: false where they otherwise typed Person
end-to-end. Pre-existing "missing lastName/personType" fixture errors
in DocumentRow.spec are out of scope.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
050f2bc929 test(stammbaum): integration tests for relationship constraints
@DataJpaTest + Postgres Testcontainer; 7 cases per Sara blocker 1:
- addRelationship_stores_and_is_readable
- addRelationship_throws_409_when_duplicate (unique_rel)
- addRelationship_throws_409_when_circular_parent
- deleteRelationship_throws_403_when_rel_belongs_to_different_person
- deleteRelationship_succeeds_for_symmetric_type_from_either_side
- setFamilyMember_true_makes_person_appear_in_network
- delete_person_cascades_to_relationships

Service now uses saveAndFlush so the unique_rel violation surfaces
synchronously inside the @Transactional method (otherwise the
DataIntegrityViolation fires at commit time, outside the try-catch).
Unit-test mocks updated accordingly.

Backend suite: 1406/1406 green.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
f29f4d3f5b feat(stammbaum): RelationshipController for the Stammbaum API
Seven endpoints in one controller, two roots:
- GET  /api/network                                  → NetworkDTO
- GET  /api/persons/{id}/relationships               → List<RelationshipDTO>
- GET  /api/persons/{id}/inferred-relationships
- GET  /api/persons/{aId}/relationship-to/{bId}      → 200 or 404
- POST /api/persons/{id}/relationships               WRITE_ALL
- DEL  /api/persons/{id}/relationships/{relId}       WRITE_ALL, 204
- PATCH /api/persons/{id}/family-member              WRITE_ALL

PersonController is intentionally untouched. Controller-boundary
validation via RelationType.valueOf catches unknown types as 400 before
the service is invoked. FamilyMemberPatchDTO is a one-field record for
the family-member toggle.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
790c6f5b02 feat(stammbaum): RelationshipService + family_member toggle (TDD)
- Add PersonService.setFamilyMember (write, @Transactional) and
  findAllFamilyMembers; PersonRepository gains the
  findByFamilyMemberTrueOrderBy projection.
- RelationshipService orchestrates PersonService + the inference
  service; never reaches into PersonRepository directly. addRelationship
  guards self-relationship, year range, circular PARENT_OF (Nora B2),
  and DataIntegrityViolation→DUPLICATE_RELATIONSHIP. deleteRelationship
  enforces ownership from either side (Nora B1).
- Extend RelationshipDTO with personDisplayName + birth/death year so
  the frontend can render rows from either viewpoint.
- 8 unit tests, written against a stub (red), then green: FORBIDDEN
  delete, CIRCULAR add, DUPLICATE add, self-relationship, year range,
  happy-path persistence, ownership-from-object, RELATIONSHIP_NOT_FOUND.

Full backend suite: 1399/1399 green.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
acea4a60f2 feat(stammbaum): inference service with BFS + LABEL_MAP (TDD)
RelationToken enum (UP/DOWN/SPOUSE/SIBLING) with reverse(), and
RelationshipInferenceService with:
- Bidirectional adjacency map: PARENT_OF emits UP and DOWN, SPOUSE_OF
  and SIBLING_OF both directions.
- Virtual SIBLING edges derived from shared parents — no SIBLING_OF
  row required for siblings to appear.
- BFS with MAX_DEPTH=8.
- 17-entry LABEL_MAP covering parent, child, spouse, sibling, grand*,
  great-grand*, uncle/aunt, niece/nephew, great-uncle/aunt, great-niece/
  nephew, in-law parent/child, sibling-in-law (both paths), cousin_1.
- "distant" fallback for any path not in LABEL_MAP.
- Two-sided labels via path reversal.

18 unit tests written first against a stub; all 18 confirmed red, then
green after implementation. PersonControllerTest's anonymous DTO updated
for the new isFamilyMember() projection.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
25f62ce93b feat(stammbaum): add backend data layer for family network
- RelationType enum (9 values), PersonRelationship entity with
  @ToString(exclude = "notes") and LAZY person FKs.
- PersonRelationshipRepository with the network bulk fetch, the
  per-person subgraph fetch, and the existsBy check for the circular
  PARENT_OF guard.
- Six DTO records: CreateRelationshipRequest, RelationshipDTO,
  PersonNodeDTO, NetworkDTO, InferredRelationshipDTO,
  InferredRelationshipWithPersonDTO. @Schema(REQUIRED) on every
  always-populated field so OpenAPI/TS codegen stays accurate.
- Person entity gains familyMember, PersonSummaryDTO gains
  isFamilyMember, both PersonRepository projections select
  p.family_member.
- Three new ErrorCodes: RELATIONSHIP_NOT_FOUND, CIRCULAR_RELATIONSHIP,
  DUPLICATE_RELATIONSHIP.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
Marcel
df6175ed2c feat(stammbaum): add V54 migration for family network
Adds persons.family_member flag and person_relationships table with
ON DELETE CASCADE on both FKs, no_self_rel check, unique_rel composite,
indexes on both person columns, and partial unique index for symmetric
SIBLING_OF pairs (LEAST/GREATEST trick).

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
f6cf2e0e42 feat(transcription): add "Alle als fertig markieren" bulk action (#345) (#352)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 51s
CI / Backend Unit Tests (push) Failing after 3m13s
## Summary

Implements the bulk "Alle als fertig markieren" action for the transcription panel requested in #345.

### Backend

- Added `PUT /api/documents/{documentId}/transcription-blocks/review-all` endpoint to `TranscriptionBlockController`, guarded with `@RequirePermission(Permission.WRITE_ALL)`
- Added `markAllBlocksReviewed(UUID documentId, UUID userId)` to `TranscriptionService` — `@Transactional`, single DB round-trip via `blockRepository.saveAll()`, emits one `BLOCK_REVIEWED` audit event per previously-unreviewed block
- Returns full updated block list (same shape as `listBlocks`) for a clean frontend update pass
- 5 new `TranscriptionServiceTest` unit tests (idempotency, audit events, empty document)
- 5 new `TranscriptionBlockControllerTest` `@WebMvcTest` tests (401, 403, 200 happy path, 200 empty, 401 user not found)
- All 68 backend tests pass

### Frontend

- Added `onMarkAllReviewed?: () => Promise<void>` prop to `TranscriptionEditView` (optional, consistent with `onTriggerOcr` pattern)
- Button placed in sticky progress header, right-aligned next to `reviewedCount / totalCount geprüft`
- Button is **disabled** (not hidden) when all blocks are already reviewed — `title="Alle Blöcke sind bereits als fertig markiert"` (Decision 1)
- Loading spinner replaces checkmark icon during operation — always shown (Decision 4, no threshold)
- Handler `markAllReviewed()` added to `documents/[id]/+page.svelte`, wired as `onMarkAllReviewed`
- 5 new `TranscriptionEditView.svelte.spec.ts` Vitest Browser component tests; all 25 tests pass

### Decisions applied

| # | Question | Choice |
|---|---|---|
| 1 | Button when all reviewed | **Disabled** with `title` tooltip |
| 2 | Audit log | **N individual BLOCK_REVIEWED events** (one per unreviewed block) |
| 3 | Atomicity | **All-or-nothing** via `@Transactional` |
| 4 | Loading indicator | **Always show** during operation |

Closes #345

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/352
2026-04-28 08:34:26 +02:00
Marcel
33ca2df45b docs(specs): add Stammbaum UI specs — tree, document badge, person edit
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m12s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 3m1s
Three standalone HTML spec files covering the initial Stammbaum release:
- stammbaum-tree-spec.html — desktop/tablet/mobile tree canvas with side panel, light + dark
- stammbaum-doc-badge-spec.html — inline relationship pill on document detail
- stammbaum-person-edit-spec.html — relationship editor card on person edit page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:09:47 +02:00
Marcel
0979302205 Revert "docs: add Stammbaum feature design spec"
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m5s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 2m58s
This reverts commit 9fb2c025cf.
2026-04-27 09:58:35 +02:00
Marcel
9fb2c025cf docs: add Stammbaum feature design spec
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
Covers: person_relationships table, family_member flag,
RelationshipInferenceService (BFS path-to-label), /stammbaum
SVG page (generational + D3-Force toggle), relationship badge
on document detail, relationship editor on person edit page,
and nav swap Briefwechsel → Stammbaum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:57:15 +02:00
Marcel
ee2de8135b fix(persons): align PersonMergePanel padding with other edit page cards
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m5s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:06:25 +02:00
Marcel
fe13df574a test(persons): fix E2E flakiness — replace waitForTimeout with waitForListbox, remove conditional assertions, fix data-hydrated selector
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Has started running
Addresses three blockers raised in PR #350 review (Felix, Sara, Tobias):

1. Replace all waitForTimeout(400) calls with waitForListbox() which uses
   waitForSelector('[role="listbox"]', { state: 'visible' }) — auto-waits
   for the debounce to resolve, faster on fast machines and reliable under CI.

2. Remove all conditional if (hasResults) / if (hasDropdown) wrappers.
   Tests now use unconditional expect(dropdown).toBeVisible() assertions so
   a missing-data condition causes an explicit failure instead of a silent
   green run.

3. Replace waitForSelector('[data-hydrated]') with waitForLoadState('networkidle')
   in getDocumentEditUrl — the data-hydrated attribute does not exist in the
   app markup and would cause a 30s timeout on every test.

4. Extract page: Page type import from @playwright/test and introduce
   waitForListbox(page: Page) helper to avoid repeating the selector pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:01:44 +02:00
Marcel
a9080e9dab test(persons): add ArrowDown forward-wrap unit test for keyboard navigation
Adds the missing 'ArrowDown from last wraps to first option' test to
close the asymmetric coverage gap noted by Sara (QA) in the review of
PR #350. The ArrowUp backward-wrap test already existed; this test
verifies the % modulo wrap works in the forward direction too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:01:44 +02:00
Marcel
e8a1cc82ff fix(persons): fix PersonTypeahead dropdown clipping with fixed positioning
The dropdown was clipped by parent containers using overflow, transform,
or stacking context via shadow-sm + z-index combinations. Adopts the same
fixed-position strategy as PersonMultiSelect: binds to the input element,
computes position via getBoundingClientRect(), and registers svelte:window
scroll/resize listeners to keep it current.

Also adds full ARIA combobox pattern (role=combobox, aria-expanded,
aria-haspopup, aria-controls, aria-activedescendant) and keyboard
navigation (ArrowDown/Up, Enter, Escape) matching TagInput's reference
implementation.

Removes the now-dead z-30/z-10 z-index workarounds from ConversationFilterBar.

Closes #343

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:01:44 +02:00
Marcel
5b18b87450 test(security): add 403 permission test for annotation DELETE endpoint
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m4s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m0s
Confirms that DELETE /api/documents/{id}/annotations/{id} requires at
least ANNOTATE_ALL; a user with only READ_ALL receives 403 Forbidden.
Closes the permission audit raised during PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
bfa8b9c147 fix(viewer): move delete button inside annotation bounds to prevent edge clipping
Repositioning from top:-8px/right:-8px to top:4px/right:4px ensures the
44px touch target stays fully within the annotation shape. Annotations drawn
near the top or right edge of the PDF page no longer risk the button being
obscured or inaccessible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
3a94d62c74 test(viewer): verify delete button click does not bubble to onclick
Documents the stopPropagation guarantee: clicking the trash button must
not trigger the annotation's onclick (which opens the block detail panel)
while the delete confirm is in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
163e99016a fix(viewer): check res.ok on orphaned annotation DELETE to surface errors
Without the guard, a failed DELETE (4xx/5xx) was silently swallowed and
annotationReloadKey was incremented anyway, leaving the annotation visible
and the user with no feedback. Now matches the deleteBlock() pattern
immediately above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
d6f3ca5c43 feat(viewer): show delete icon on annotation for direct block deletion (#339)
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
108edff8d2 feat(persons): show merge panel inline on edit page, remove Gefahrenzone accordion
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Closes #342. The PersonDangerZone collapsible wrapper is removed; PersonMergePanel
is now rendered directly in the edit page with its own red border (border-red-200),
preserving the {#key person.id} state-reset behaviour and the two-step merge flow.

Fix PersonTypeahead mock to use Svelte 5 functional stub (not Svelte 3/4 $$ internals).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:54:45 +02:00
Marcel
3d3fe8d626 fix(pagination): add sr-only span to preserve aria-current on mobile AT
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
When the mobile label is aria-hidden and the desktop button container is
display:none (below sm:), mobile screen reader users had no aria-current
indicator. Added a sr-only span with aria-current="page" that stays in
the AT tree at all breakpoints regardless of CSS display state.

On desktop the active page button also carries aria-current — both
announce the same page information, which is acceptable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
31e5573eab fix(pagination): hide mobile page label from AT tree with aria-hidden
The mobile 'Seite X von Y' span had aria-current='page', which created two
elements announcing the current page on wide screens: the hidden mobile label
and the active desktop button. On sm:+ screens the mobile span is display:none
(removed from AT tree), but on small screens both the span and the desktop
button were redundant.

Replace aria-current with aria-hidden='true' on the mobile label so AT always
relies on the desktop button's aria-current. Updates spec test accordingly and
adds a second assertion in a broader test context (Decision Queue #1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
934a00feb3 fix(pagination): use stable key in {#each} and fix duplicate page number bug
Replaces position-based key `i` with `entry === null ? 'ellipsis-' + i : entry`
so DOM reconciliation is stable when the window shifts (Decision Queue #2).

The index-based key was masking a duplicate-push bug in pageWindow: when
windowStart === first+1 or windowEnd === last-1, the loop already included that
number, causing Svelte to throw `each_key_duplicate` once stable keys are used.
Fixed the bridge-page conditions to use first+2 / last-2 thresholds so the loop
and the bridge branches never push the same page number.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
be27489618 test(pagination): fix test name typo and add totalPages===2 boundary test
Renames 'page button buttons' → 'page buttons container' (Decision Queue #3).
Adds 'renders both pages without ellipsis when totalPages is 2' to cover the
boundary between the 1-page (hidden) and full-ellipsis-window cases (Decision Queue #5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
4e486a31cf feat(pagination): add numbered page-jump buttons to document search
Adds an ellipsis-style numbered page button row (1 … 4 5 6 … 12) to
Pagination.svelte. Buttons are hidden on mobile (sm: breakpoint) and fall
back to the existing prev/next layout. Active page uses brand-navy
background. Client-side clamping via makeHref(entry - 1) satisfies AC3.
i18n key pagination_page_button added for de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:53:17 +02:00
Marcel
2c5877ea9e fix(a11y): fix ProgressRing text label contrast and add no-restricted-syntax lint rule for text-accent
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
ProgressRing used text-accent (#a1dcd8) on a percentage text label —
same WCAG 2.1 AA failure as #341. Switched to text-primary.

Also adds ESLint no-restricted-syntax rule (scoped to *.svelte files) that
blocks future text-accent usage in JavaScript string literals inside Svelte
class expressions. The rule caught both violations at once; both are now fixed.
The rule is scoped to .svelte files so test assertions against 'text-accent'
strings in .spec.ts files are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:46:44 +02:00
Marcel
cfbe33140c fix(viewer): replace text-accent with text-primary on annotation toggle inactive state
Fixes WCAG 2.1 AA contrast failure (#341): text-accent (#a1dcd8) on light
PDF control bar was 1.52:1 — well below the 4.5:1 AA minimum. text-primary
resolves to #012851 in light mode (14.5:1) and #a1dcd8 in dark mode (9:1) —
both states pass AA in both themes.

Adds PdfControls.svelte.spec.ts with 5 tests covering toggle visibility,
label strings, and the contrast-safe class assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:46:44 +02:00
e8d1835ae1 feat(nav): add tooltip and cursor:pointer to notification bell, fix ThemeToggle i18n (#344) (#351)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Closes #344

## What was implemented

### Commit 1 — `feat(nav): add cursor-pointer and tooltip to notification bell`
- Extracted `bellLabel` as `$derived` in `NotificationBell.svelte` — eliminates the duplicated inline ternary and keeps tooltip/label in sync reactively
- Added `title={bellLabel}` to the bell `<button>` — native tooltip mirrors `aria-label` in both zero and non-zero unread states
- Added `cursor-pointer` to the bell button's class list
- Added global `button { cursor: pointer; }` rule in `@layer base` of `layout.css` — prevents future regressions (global scope per Decision Queue)
- Added 3 component tests in `NotificationBell.svelte.spec.ts`: cursor-pointer class present, title equals aria-label when unread=0, title equals aria-label when unread=3

### Commit 2 — `fix(nav): replace hardcoded ThemeToggle title with Paraglide i18n keys`
- Added `theme_toggle_to_light` / `theme_toggle_to_dark` keys to `de/en/es` messages
- Extracted `themeLabel` as `$derived` in `ThemeToggle.svelte` and bound both `aria-label` and `title` to it
- Fixes the pre-existing hardcoded English strings (`'light mode'` / `'dark mode'`) per Decision Queue resolution

Touch target size was descoped per the Decision Queue.

## Decision Queue resolutions (from issue #344)
- **cursor-pointer scope**: global via `@layer base` 
- **ThemeToggle scope**: fixed in this issue 
- **Touch target**: descoped 

## Test results
All 5 `NotificationBell` tests pass.

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/351
2026-04-26 21:45:40 +02:00
111 changed files with 10301 additions and 165 deletions

View File

@@ -0,0 +1,3 @@
### Mark all blocks as reviewed
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
Authorization: Basic admin admin123

View File

@@ -6,6 +6,7 @@ import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -47,6 +48,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())

View File

@@ -1,5 +1,6 @@
package org.raddatz.familienarchiv.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
@@ -45,7 +46,7 @@ public class TranscriptionBlockController {
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock(
@PathVariable UUID documentId,
@RequestBody CreateTranscriptionBlockDTO dto,
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.createBlock(documentId, dto, userId);
@@ -56,7 +57,7 @@ public class TranscriptionBlockController {
public TranscriptionBlock updateBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@RequestBody UpdateTranscriptionBlockDTO dto,
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
@@ -90,6 +91,15 @@ public class TranscriptionBlockController {
return transcriptionService.reviewBlock(documentId, blockId, userId);
}
@PutMapping("/review-all")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.markAllBlocksReviewed(documentId, userId);
}
@GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory(

View File

@@ -1,14 +1,21 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateTranscriptionBlockDTO {
@Min(0)
private int pageNumber;
@@ -22,4 +29,8 @@ public class CreateTranscriptionBlockDTO {
private double height;
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -17,6 +17,7 @@ public interface PersonSummaryDTO {
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
boolean isFamilyMember();
long getDocumentCount();
default String getDisplayName() {

View File

@@ -1,13 +1,24 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UpdateTranscriptionBlockDTO {
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -96,6 +96,14 @@ public enum ErrorCode {
/** Internal inconsistency: expected training run row was not found after creation. 500 */
OCR_TRAINING_CONFLICT,
// --- Relationships (Stammbaum) ---
/** A relationship row with the given ID does not exist. 404 */
RELATIONSHIP_NOT_FOUND,
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */
TAG_NOT_FOUND,

View File

@@ -47,6 +47,11 @@ public class Person {
private Integer birthYear;
private Integer deathYear;
@Column(name = "family_member", nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean familyMember = false;
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
// Uses entity relationship rather than cross-domain repository access, avoiding a
// separate DB roundtrip while respecting domain boundaries.

View File

@@ -0,0 +1,23 @@
package org.raddatz.familienarchiv.model;
import java.util.UUID;
/**
* Published by PersonService when a save changes Person.getDisplayName() — i.e.
* any mutation to the fields that DisplayNameFormatter consumes (title,
* firstName, lastName). Listeners on the transcription side rewrite block text
* and sidecar entries that reference the old name.
*
* <p>This is the first custom application event in the codebase. The previous
* only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's
* built-in ApplicationReadyEvent. Future cross-domain decoupling should follow
* the same shape: record-typed event in model/, listener in the consuming
* domain's service/ package, synchronous @EventListener inside the publisher's
* transaction unless the workload genuinely needs to defer.
*/
public record PersonDisplayNameChangedEvent(
UUID personId,
String oldDisplayName,
String newDisplayName
) {
}

View File

@@ -0,0 +1,30 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PersonMention {
@NotNull
@Column(name = "person_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID personId;
@NotNull
@Size(max = 200)
@Column(name = "display_name", nullable = false, length = 200)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String displayName;
}

View File

@@ -7,6 +7,8 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@@ -33,6 +35,14 @@ public class TranscriptionBlock {
@Column(columnDefinition = "TEXT")
private String text;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "transcription_block_mentioned_persons",
joinColumns = @JoinColumn(name = "block_id"))
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
@Column(length = 200)
private String label;

View File

@@ -0,0 +1,55 @@
package org.raddatz.familienarchiv.relationship;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.raddatz.familienarchiv.model.Person;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "person_relationships")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "notes")
public class PersonRelationship {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "person_id", nullable = false)
@JsonIgnore
private Person person;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "related_person_id", nullable = false)
@JsonIgnore
private Person relatedPerson;
@Enumerated(EnumType.STRING)
@Column(name = "relation_type", nullable = false, length = 30)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private RelationType relationType;
@Column(name = "from_year")
private Integer fromYear;
@Column(name = "to_year")
private Integer toYear;
@Column(length = 2000)
private String notes;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Instant createdAt;
}

View File

@@ -0,0 +1,49 @@
package org.raddatz.familienarchiv.relationship;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Repository
public interface PersonRelationshipRepository extends JpaRepository<PersonRelationship, UUID> {
/**
* Bulk fetch for the network endpoint — pulls only edges of the given types.
* The service filters by family_member afterwards.
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.relationType IN :types")
List<PersonRelationship> findAllByRelationTypeIn(@Param("types") Collection<RelationType> types);
/** Used for the circular-PARENT_OF check in {@code addRelationship}. */
boolean existsByPersonIdAndRelatedPersonIdAndRelationType(
UUID personId, UUID relatedPersonId, RelationType relationType);
/**
* All edges incident on {@code personId} (either side) restricted to the given types.
* Used by the inference service to load a person's local subgraph for BFS.
*/
@Query("SELECT r FROM PersonRelationship r " +
"WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " +
"AND r.relationType IN :types")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
@Param("personId") UUID personId,
@Param("types") Collection<RelationType> types);
/**
* All edges incident on {@code personId} (either side), all types.
* Used by the "direct relationships" listings (person edit, side panel).
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.person.id = :personId OR r.relatedPerson.id = :personId")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
}

View File

@@ -0,0 +1,24 @@
package org.raddatz.familienarchiv.relationship;
/**
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
*
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
*/
public enum RelationToken {
UP,
DOWN,
SPOUSE,
SIBLING;
public RelationToken reverse() {
return switch (this) {
case UP -> DOWN;
case DOWN -> UP;
case SPOUSE -> SPOUSE;
case SIBLING -> SIBLING;
};
}
}

View File

@@ -0,0 +1,20 @@
package org.raddatz.familienarchiv.relationship;
/**
* Family-network relationship taxonomy.
*
* <p>Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once;
* the inference service walks them in both directions. {@link #PARENT_OF} is
* directional: A PARENT_OF B means A is the parent.
*/
public enum RelationType {
PARENT_OF,
SPOUSE_OF,
SIBLING_OF,
FRIEND,
COLLEAGUE,
EMPLOYER,
DOCTOR,
NEIGHBOR,
OTHER
}

View File

@@ -0,0 +1,84 @@
package org.raddatz.familienarchiv.relationship;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
/**
* Stammbaum API. Endpoints split across two roots:
* <ul>
* <li>{@code /api/network} — the family graph</li>
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
* (PersonController is intentionally left untouched)</li>
* </ul>
*/
@RestController
@RequiredArgsConstructor
public class RelationshipController {
private final RelationshipService relationshipService;
// READ endpoints carry no @RequirePermission: all authenticated users may read the family graph.
// Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule.
@GetMapping("/api/network")
public NetworkDTO getNetwork() {
return relationshipService.getFamilyNetwork();
}
@GetMapping("/api/persons/{id}/relationships")
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
return relationshipService.getRelationships(id);
}
@GetMapping("/api/persons/{id}/inferred-relationships")
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
return relationshipService.getInferredRelationships(id);
}
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
return relationshipService.getRelationshipBetween(aId, bId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "No relationship path between " + aId + " and " + bId));
}
@PostMapping("/api/persons/{id}/relationships")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<RelationshipDTO> addRelationship(
@PathVariable UUID id,
@Valid @RequestBody CreateRelationshipRequest dto) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(relationshipService.addRelationship(id, dto));
}
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
relationshipService.deleteRelationship(id, relId);
}
@PatchMapping("/api/persons/{id}/family-member")
@RequirePermission(Permission.WRITE_ALL)
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
return relationshipService.setFamilyMember(id, dto.familyMember());
}
}

View File

@@ -0,0 +1,211 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* Derives indirect family relationships by BFS over the family-graph subset
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
* row is required.
*/
@Service
@RequiredArgsConstructor
public class RelationshipInferenceService {
// 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins —
// the practical horizon for a 18991950 family archive. Paths longer than this are
// classified as LABEL_DISTANT and rarely carry meaningful relationship labels.
static final int MAX_DEPTH = 8;
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
static final String LABEL_DISTANT = "distant";
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private static Map<List<RelationToken>, String> buildLabelMap() {
Map<List<RelationToken>, String> m = new HashMap<>();
m.put(List.of(RelationToken.UP), "parent");
m.put(List.of(RelationToken.DOWN), "child");
m.put(List.of(RelationToken.SPOUSE), "spouse");
m.put(List.of(RelationToken.SIBLING), "sibling");
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
return Collections.unmodifiableMap(m);
}
/**
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
* assertions in unit tests.
*/
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
if (from.equals(to)) return Optional.empty();
Map<UUID, List<Edge>> adj = buildAdjacency();
return bfs(adj, from, to);
}
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
if (aToB.isEmpty()) return Optional.empty();
List<RelationToken> path = aToB.get();
return Optional.of(new InferredRelationshipDTO(
labelFor(path),
labelFor(reversePath(path)),
path.size()));
}
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
Map<UUID, List<Edge>> adj = buildAdjacency();
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
shortestPaths.remove(personId);
if (shortestPaths.isEmpty()) return List.of();
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
Map<UUID, Person> byId = new HashMap<>();
for (Person p : personService.getAllById(ids)) {
byId.put(p.getId(), p);
}
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
for (UUID id : ids) {
Person p = byId.get(id);
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
.thenComparing(d -> d.person().displayName()));
return out;
}
static String labelFor(List<RelationToken> path) {
String specific = LABEL_MAP.get(path);
return specific != null ? specific : LABEL_DISTANT;
}
private static List<RelationToken> reversePath(List<RelationToken> path) {
List<RelationToken> reversed = new ArrayList<>(path.size());
for (int i = path.size() - 1; i >= 0; i--) {
reversed.add(path.get(i).reverse());
}
return List.copyOf(reversed);
}
private Map<UUID, List<Edge>> buildAdjacency() {
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
Map<UUID, List<Edge>> adj = new HashMap<>();
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
for (PersonRelationship e : edges) {
UUID a = e.getPerson().getId();
UUID b = e.getRelatedPerson().getId();
switch (e.getRelationType()) {
case PARENT_OF -> {
addEdge(adj, a, b, RelationToken.DOWN);
addEdge(adj, b, a, RelationToken.UP);
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
}
case SPOUSE_OF -> {
addEdge(adj, a, b, RelationToken.SPOUSE);
addEdge(adj, b, a, RelationToken.SPOUSE);
}
case SIBLING_OF -> {
addEdge(adj, a, b, RelationToken.SIBLING);
addEdge(adj, b, a, RelationToken.SIBLING);
}
default -> { /* family graph excludes other types */ }
}
}
for (List<UUID> children : parentToChildren.values()) {
for (int i = 0; i < children.size(); i++) {
for (int j = i + 1; j < children.size(); j++) {
UUID c1 = children.get(i);
UUID c2 = children.get(j);
addEdge(adj, c1, c2, RelationToken.SIBLING);
addEdge(adj, c2, c1, RelationToken.SIBLING);
}
}
}
return adj;
}
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
}
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
if (e.target().equals(to)) return Optional.of(nextPath);
queue.add(e.target());
}
}
return Optional.empty();
}
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
queue.add(e.target());
}
}
return shortest;
}
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
out.addAll(prefix);
out.add(next);
return List.copyOf(out);
}
private record Edge(UUID target, RelationToken token) {}
}

View File

@@ -0,0 +1,168 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* Owns the {@code person_relationships} table and the family_member flag.
* Always orchestrates {@link PersonService} for cross-domain access — never
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
*/
@Service
@RequiredArgsConstructor
public class RelationshipService {
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private final RelationshipInferenceService inferenceService;
public List<RelationshipDTO> getRelationships(UUID personId) {
personService.getById(personId);
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
return rels.stream().map(RelationshipService::toDTO).toList();
}
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
personService.getById(personId);
return inferenceService.findAllFor(personId);
}
public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
personService.getById(a);
personService.getById(b);
return inferenceService.infer(a, b);
}
public NetworkDTO getFamilyNetwork() {
// Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
List<Person> familyMembers = personService.findAllFamilyMembers();
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
}
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
List<RelationshipDTO> edges = new ArrayList<>();
for (PersonRelationship r : familyEdges) {
UUID p = r.getPerson().getId();
UUID rp = r.getRelatedPerson().getId();
if (familyIds.contains(p) && familyIds.contains(rp)) {
edges.add(toDTO(r));
}
}
return new NetworkDTO(nodes, edges);
}
@Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId());
validateYears(dto.fromYear(), dto.toYear());
if (dto.relationType() == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
}
PersonRelationship rel = PersonRelationship.builder()
.person(person)
.relatedPerson(relatedPerson)
.relationType(dto.relationType())
.fromYear(dto.fromYear())
.toYear(dto.toYear())
.notes(blankToNull(dto.notes()))
.build();
try {
// saveAndFlush so the unique_rel constraint violates synchronously and is
// caught here, not at commit time outside the @Transactional boundary.
return toDTO(relationshipRepository.saveAndFlush(rel));
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
}
}
@Transactional
public void deleteRelationship(UUID personId, UUID relId) {
PersonRelationship rel = relationshipRepository.findById(relId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
throw DomainException.forbidden(
"Relationship " + relId + " does not belong to person " + personId);
}
relationshipRepository.delete(rel);
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
return personService.setFamilyMember(personId, familyMember);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
}
}
private static RelationshipDTO toDTO(PersonRelationship r) {
Person p = r.getPerson();
Person rp = r.getRelatedPerson();
return new RelationshipDTO(
r.getId(),
p.getId(),
rp.getId(),
p.getDisplayName(),
p.getBirthYear(),
p.getDeathYear(),
rp.getDisplayName(),
rp.getBirthYear(),
rp.getDeathYear(),
r.getRelationType(),
r.getFromYear(),
r.getToYear(),
r.getNotes());
}
}

View File

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

View File

@@ -0,0 +1,4 @@
package org.raddatz.familienarchiv.relationship.dto;
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
public record FamilyMemberPatchDTO(boolean familyMember) {}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Pairwise inferred relationship for the document badge.
* {@code labelFromA} reads "Person B, from A's point of view" and vice-versa
* (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater").
*/
public record InferredRelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry
* is a derived relationship to another family member, labelled from the
* requesting person's perspective.
*/
public record InferredRelationshipWithPersonDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -0,0 +1,11 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */
public record NetworkDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonNodeDTO> nodes,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
) {}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */
public record PersonNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
Integer birthYear,
Integer deathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
) {}

View File

@@ -0,0 +1,32 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.relationship.RelationType;
import java.util.UUID;
/**
* Wire shape for one stored relationship row. Both sides include name + years
* so the frontend can render the row from either perspective (e.g. on the
* subject's page the row reads "Elternteil von [related]"; on the object's
* page it reads "Kind von [person]").
*
* <p>Storage truth: {@code personId} is the {@code person_id} column,
* {@code relatedPersonId} is the {@code related_person_id} column. The
* frontend determines orientation by comparing against the viewpoint.
*/
public record RelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName,
Integer personBirthYear,
Integer personDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName,
Integer relatedPersonBirthYear,
Integer relatedPersonDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
Integer fromYear,
Integer toYear,
String notes
) {}

View File

@@ -26,6 +26,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
// Stammbaum-Knoten: alle Personen mit family_member = true.
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
@@ -38,6 +41,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -50,6 +54,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -58,7 +63,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)

View File

@@ -29,6 +29,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
List<TranscriptionBlock> findByMentionedPersons_PersonId(UUID personId);
void deleteByAnnotationId(UUID annotationId);
int countByDocumentId(UUID documentId);

View File

@@ -13,11 +13,13 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +33,7 @@ public class PersonService {
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
private final ApplicationEventPublisher eventPublisher;
public List<PersonSummaryDTO> findAll(String q) {
if (q == null) {
@@ -58,6 +61,17 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId);
person.setFamilyMember(familyMember);
return personRepository.save(person);
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
@@ -146,6 +160,7 @@ public class PersonService {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
String oldDisplayName = person.getDisplayName();
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
@@ -154,7 +169,12 @@ public class PersonService {
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
return personRepository.save(person);
Person saved = personRepository.save(person);
String newDisplayName = saved.getDisplayName();
if (!Objects.equals(oldDisplayName, newDisplayName)) {
eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName));
}
return saved;
}
@Transactional

View File

@@ -205,6 +205,18 @@ public class TranscriptionService {
return saved;
}
@Transactional
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
for (TranscriptionBlock block : blocks) {
if (!block.isReviewed()) {
block.setReviewed(true);
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
}
}
return blockRepository.saveAll(blocks);
}
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);

View File

@@ -0,0 +1,30 @@
-- Family network: marks a Person as a tree node and stores typed relationships
-- between two persons. The tree page (/stammbaum) only shows persons with
-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once;
-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the
-- reverse direction.
ALTER TABLE persons
ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE person_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
relation_type VARCHAR(30) NOT NULL,
from_year INTEGER,
to_year INTEGER,
notes VARCHAR(2000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_self_rel CHECK (person_id <> related_person_id),
CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type)
);
CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id);
CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id);
-- Symmetric SIBLING_OF: enforce only one row per unordered pair.
CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SIBLING_OF';

View File

@@ -0,0 +1,6 @@
-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the
-- SIBLING_OF index added in V54.
CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SPOUSE_OF';

View File

@@ -0,0 +1,25 @@
-- Sidecar table for @-mentions inside transcription_blocks.text.
-- Each row is one (block_id, person_id, display_name) tuple emitted by the
-- typeahead in the transcription editor. block.text contains the literal
-- "@DisplayName" — the UUID lives only here so historical text stays clean.
--
-- Schema choice: child table via @ElementCollection (mirrors the established
-- UserGroup.permissions / group_permissions pattern), NOT JSONB. The "show
-- all blocks mentioning person X" query on the person detail page joins on
-- the indexed person_id column — equally fast as JSONB GIN containment, no
-- new dependency. document_comments.comment_mentions stays as a many-to-many
-- to AppUser; the divergence is intentional: Person mentions need lazy
-- degradation when a person is deleted (no FK), while user mentions don't.
--
-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to
-- remain visible as plain unlinked text inside the transcription rather than
-- vanishing or cascade-deleting the block.
CREATE TABLE transcription_block_mentioned_persons (
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
person_id UUID NOT NULL,
display_name VARCHAR(200) NOT NULL
);
CREATE INDEX idx_tbmp_person_id ON transcription_block_mentioned_persons(person_id);
CREATE INDEX idx_tbmp_block_id ON transcription_block_mentioned_persons(block_id);

View File

@@ -154,6 +154,13 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {

View File

@@ -85,6 +85,7 @@ class PersonControllerTest {
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public String getNotes() { return null; }
public boolean isFamilyMember() { return false; }
public long getDocumentCount() { return 0; }
};
}

View File

@@ -183,6 +183,36 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
+ "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
@Test
@@ -260,6 +290,13 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception {
@@ -373,4 +410,63 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.reviewed").value(true));
}
// ─── PUT .../review-all ───────────────────────────────────────────────────
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
@Test
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
TranscriptionBlock b1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 1").sortOrder(0).reviewed(true).build();
TranscriptionBlock b2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 2").sortOrder(1).reviewed(true).build();
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of(b1, b2));
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].reviewed").value(true))
.andExpect(jsonPath("$[1].reviewed").value(true));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of());
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -0,0 +1,160 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(RelationshipController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class RelationshipControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean RelationshipService relationshipService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final UUID PERSON_ID = UUID.randomUUID();
private static final UUID OTHER_ID = UUID.randomUUID();
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception {
when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty());
mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name()));
}
@Test
void getRelationships_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID))
.andExpect(status().isUnauthorized());
}
@Test
void getNetwork_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/network"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"familyMember\":true}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980,
"Bob Müller", 1930, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
mockMvc.perform(get("/api/network"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller"))
.andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
InferredRelationshipWithPersonDTO inferred =
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
when(relationshipService.getInferredRelationships(PERSON_ID))
.thenReturn(List.of(inferred));
mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].label").value("Großvater"))
.andExpect(jsonPath("$[0].hops").value(2));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
RelationshipDTO created = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", null, null,
"Bob Müller", null, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception {
UUID relId = UUID.randomUUID();
doNothing().when(relationshipService).deleteRelationship(any(), any());
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,353 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.service.PersonService;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
import static org.raddatz.familienarchiv.relationship.RelationType.*;
/**
* Felix Brandt — TDD red phase for RelationshipInferenceService.
* <p>
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
* a small graph through the mocked repository and asserts the exact abstract
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
* which asserts the fallback label, and {@code returns_empty_when_no_path}
* which asserts no result.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipInferenceServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@InjectMocks RelationshipInferenceService service;
// --- 1: parent ---
@Test
void parent_path_emits_UP() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(child.getId(), parent.getId()))
.hasValue(List.of(UP));
}
// --- 2: child ---
@Test
void child_path_emits_DOWN() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(parent.getId(), child.getId()))
.hasValue(List.of(DOWN));
}
// --- 3: spouse ---
@Test
void spouse_path_emits_SPOUSE() {
Person a = person();
Person b = person();
givenEdges(spouseOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SPOUSE));
}
// --- 4: sibling ---
@Test
void sibling_path_emits_SIBLING() {
Person a = person();
Person b = person();
givenEdges(siblingOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SIBLING));
}
// --- 5: grandparent (UP, UP) ---
@Test
void grandparent_path_emits_UP_UP() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
.hasValue(List.of(UP, UP));
}
// --- 6: grandchild (DOWN, DOWN) ---
@Test
void grandchild_path_emits_DOWN_DOWN() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
.hasValue(List.of(DOWN, DOWN));
}
// --- 7: great-grandparent (UP, UP, UP) ---
@Test
void great_grandparent_path_emits_UP_UP_UP() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(gc.getId(), g.getId()))
.hasValue(List.of(UP, UP, UP));
}
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
@Test
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(g.getId(), gc.getId()))
.hasValue(List.of(DOWN, DOWN, DOWN));
}
// --- 9: uncle/aunt (UP, SIBLING) ---
@Test
void uncle_aunt_path_emits_UP_SIBLING() {
Person grandparent = person();
Person parent = person();
Person uncle = person();
Person me = person();
// grandparent has two children: parent and uncle. me is parent's child.
givenEdges(
parentOf(grandparent, parent),
parentOf(grandparent, uncle),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
.hasValue(List.of(UP, SIBLING));
}
// --- 10: niece/nephew (SIBLING, DOWN) ---
@Test
void niece_nephew_path_emits_SIBLING_DOWN() {
Person grandparent = person();
Person uncle = person();
Person sibling = person();
Person niece = person();
// grandparent has uncle + sibling; sibling has niece.
givenEdges(
parentOf(grandparent, uncle),
parentOf(grandparent, sibling),
parentOf(sibling, niece));
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
.hasValue(List.of(SIBLING, DOWN));
}
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
@Test
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
Person ggp = person();
Person grandparent = person();
Person greatUncle = person();
Person parent = person();
Person me = person();
givenEdges(
parentOf(ggp, grandparent),
parentOf(ggp, greatUncle),
parentOf(grandparent, parent),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
.hasValue(List.of(UP, UP, SIBLING));
}
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
@Test
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
Person grandparent = person();
Person sibling = person();
Person greatUncle = person();
Person niece = person();
Person greatNiece = person();
givenEdges(
parentOf(grandparent, sibling),
parentOf(grandparent, greatUncle),
parentOf(sibling, niece),
parentOf(niece, greatNiece));
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
.hasValue(List.of(SIBLING, DOWN, DOWN));
}
// --- 13: parent-in-law (SPOUSE, UP) ---
@Test
void inlaw_parent_path_emits_SPOUSE_UP() {
Person inlaw = person();
Person spouse = person();
Person me = person();
givenEdges(
parentOf(inlaw, spouse),
spouseOf(me, spouse));
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
.hasValue(List.of(SPOUSE, UP));
}
// --- 14: child-in-law (DOWN, SPOUSE) ---
@Test
void inlaw_child_path_emits_DOWN_SPOUSE() {
Person me = person();
Person child = person();
Person inlawChild = person();
givenEdges(
parentOf(me, child),
spouseOf(child, inlawChild));
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
.hasValue(List.of(DOWN, SPOUSE));
}
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
@Test
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
Person me = person();
Person spouse = person();
Person spouseSibling = person();
givenEdges(
spouseOf(me, spouse),
siblingOf(spouse, spouseSibling));
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
.hasValue(List.of(SPOUSE, SIBLING));
}
// --- 16: cousin (UP, SIBLING, DOWN) ---
@Test
void cousin_1_path_emits_UP_SIBLING_DOWN() {
Person ggp = person();
Person parentMine = person();
Person uncle = person();
Person me = person();
Person cousin = person();
givenEdges(
parentOf(ggp, parentMine),
parentOf(ggp, uncle),
parentOf(parentMine, me),
parentOf(uncle, cousin));
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
.hasValue(List.of(UP, SIBLING, DOWN));
}
// --- 17: distant (label fallback for long chains) ---
@Test
void distant_label_for_long_chain() {
// Seven-generation ancestor: chain of seven PARENT_OF edges.
Person a0 = person();
Person a1 = person();
Person a2 = person();
Person a3 = person();
Person a4 = person();
Person a5 = person();
Person a6 = person();
Person a7 = person();
givenEdges(
parentOf(a0, a1),
parentOf(a1, a2),
parentOf(a2, a3),
parentOf(a3, a4),
parentOf(a4, a5),
parentOf(a5, a6),
parentOf(a6, a7));
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
assertThat(inferred).hasValueSatisfying(r -> {
assertThat(r.hops()).isEqualTo(7);
assertThat(r.labelFromA()).isEqualTo("distant");
assertThat(r.labelFromB()).isEqualTo("distant");
});
}
// --- 18: no path ---
@Test
void returns_empty_when_no_path() {
Person a = person();
Person b = person();
// No edges between them.
givenEdges(/* none */);
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
}
// --- 19: findAllFor delegates person resolution to PersonService ---
@Test
void findAllFor_resolves_persons_via_PersonService() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
when(personService.getAllById(anyList())).thenReturn(List.of(child));
List<InferredRelationshipWithPersonDTO> results = service.findAllFor(parent.getId());
assertThat(results).hasSize(1);
assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName());
}
// --- helpers ---
private void givenEdges(PersonRelationship... edges) {
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
}
private static Person person() {
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child) {
return edge(parent, child, PARENT_OF);
}
private static PersonRelationship spouseOf(Person a, Person b) {
return edge(a, b, SPOUSE_OF);
}
private static PersonRelationship siblingOf(Person a, Person b) {
return edge(a, b, SIBLING_OF);
}
private static PersonRelationship edge(Person a, Person b, RelationType type) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(type)
.createdAt(Instant.now())
.build();
}
}

View File

@@ -0,0 +1,181 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Sara blocker 1 — service+DB integration over the family-network constraints.
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
* sibling index actually fire.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({
PostgresContainerConfig.class,
FlywayConfig.class,
RelationshipService.class,
RelationshipInferenceService.class,
PersonService.class
})
class RelationshipServiceIntegrationTest {
@Autowired RelationshipService relationshipService;
@Autowired PersonRepository personRepository;
@Autowired PersonRelationshipRepository relationshipRepository;
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
@Autowired PersonNameAliasRepository aliasRepository;
@Autowired EntityManager entityManager;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
relationshipRepository.deleteAll();
aliasRepository.deleteAll();
personRepository.deleteAll();
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
}
@Test
void addRelationship_stores_and_is_readable() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.getId());
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
assertThat(rels).hasSize(1);
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
}
@Test
void addRelationship_throws_409_when_duplicate() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
}
@Test
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
// Charlie is unrelated to this row.
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
// The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent();
}
@Test
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
relationshipService.deleteRelationship(bob.getId(), created.id());
assertThat(relationshipRepository.findById(created.id())).isEmpty();
}
@Test
void setFamilyMember_true_makes_person_appear_in_network() {
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
NetworkDTO before = relationshipService.getFamilyNetwork();
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
relationshipService.setFamilyMember(charlie.getId(), true);
NetworkDTO after = relationshipService.getFamilyNetwork();
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
assertThat(after.edges())
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
}
@Test
void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent();
// Detach managed entities so deleteById's cascade isn't fought by the
// persistence context (the rel row still references bob in memory).
entityManager.flush();
entityManager.clear();
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
personRepository.deleteById(bob.getId());
personRepository.flush();
assertThat(relationshipRepository.findById(relId)).isEmpty();
}
}

View File

@@ -0,0 +1,209 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Felix Brandt — TDD red for RelationshipService domain rules.
*
* <p>Required by the plan (Nora blockers 1 + 2):
* <ul>
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
* </ul>
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
* and ownership permitted from either side.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@Mock RelationshipInferenceService inferenceService;
@InjectMocks RelationshipService service;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
alice = person("Alice");
bob = person("Bob");
charlie = person("Charlie");
}
// --- Nora blocker 1 ---
@Test
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
verify(relationshipRepository, never()).delete(any());
}
// --- Nora blocker 2 ---
@Test
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
when(personService.getById(bob.getId())).thenReturn(bob);
when(personService.getById(alice.getId())).thenReturn(alice);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_persists_with_storage_truth() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
PersonRelationship r = inv.getArgument(0);
r.setId(UUID.randomUUID());
r.setCreatedAt(Instant.now());
return r;
});
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
assertThat(result.fromYear()).isEqualTo(1900);
assertThat(result.notes()).isEqualTo("first born");
}
@Test
void deleteRelationship_succeeds_when_viewpoint_is_object() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
// Bob is the storage related_person; deleting from his viewpoint should work.
service.deleteRelationship(bob.getId(), relId);
verify(relationshipRepository).delete(rel);
}
@Test
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
UUID relId = UUID.randomUUID();
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
@Test
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
// Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
// Only the alice→bob edge must appear in the returned NetworkDTO.
UUID aliceBobRelId = UUID.randomUUID();
UUID aliceCharlieRelId = UUID.randomUUID();
PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);
when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));
NetworkDTO result = service.getFamilyNetwork();
assertThat(result.nodes()).hasSize(2);
assertThat(result.edges()).hasSize(1);
assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
}
// --- helpers ---
private static Person person(String name) {
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
return PersonRelationship.builder()
.id(id)
.person(parent)
.relatedPerson(child)
.relationType(RelationType.PARENT_OF)
.createdAt(Instant.now())
.build();
}
}

View File

@@ -0,0 +1,95 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TranscriptionBlockMentionsRepositoryTest {
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired DocumentRepository documentRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired EntityManager em;
private UUID documentId;
private UUID annotationId;
@BeforeEach
void setUp() {
Document doc = documentRepository.save(Document.builder()
.title("Letter")
.originalFilename("letter.pdf")
.status(DocumentStatus.UPLOADED)
.build());
documentId = doc.getId();
DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1")
.build());
annotationId = annotation.getId();
}
@Test
void mentionedPersons_roundTripsTwoEntries() {
UUID auguste = UUID.randomUUID();
UUID hermann = UUID.randomUUID();
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId)
.documentId(documentId)
.text("Liebe Tante @Auguste Raddatz, Onkel @Hermann Müller schreibt …")
.sortOrder(0)
.mentionedPersons(List.of(
new PersonMention(auguste, "Auguste Raddatz"),
new PersonMention(hermann, "Hermann Müller")
))
.build());
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(auguste, "Auguste Raddatz"),
org.assertj.core.groups.Tuple.tuple(hermann, "Hermann Müller"));
}
@Test
void mentionedPersons_defaultsToEmptyList_whenNotSet() {
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId)
.documentId(documentId)
.text("Plain text without mentions")
.sortOrder(0)
.build());
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getMentionedPersons()).isEmpty();
}
}

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -10,11 +11,13 @@ import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@@ -31,6 +34,7 @@ class PersonServiceTest {
@Mock PersonRepository personRepository;
@Mock PersonNameAliasRepository aliasRepository;
@Mock ApplicationEventPublisher eventPublisher;
@InjectMocks PersonService personService;
// ─── getById ─────────────────────────────────────────────────────────────
@@ -242,6 +246,77 @@ class PersonServiceTest {
assertThat(result.getAlias()).isEqualTo("Anna Alt");
}
// ─── updatePerson (display-name change event) ────────────────────────────
@Test
void updatePerson_publishesEvent_whenTitleChanges() {
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).title("Herr").firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).build();
String oldName = existing.getDisplayName();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setTitle("Frau"); dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
personService.updatePerson(id, dto);
ArgumentCaptor<PersonDisplayNameChangedEvent> captor =
ArgumentCaptor.forClass(PersonDisplayNameChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
PersonDisplayNameChangedEvent event = captor.getValue();
assertThat(event.personId()).isEqualTo(id);
assertThat(event.oldDisplayName()).isEqualTo(oldName);
assertThat(event.newDisplayName())
.isNotEqualTo(oldName)
.contains("Frau");
}
@Test
void updatePerson_doesNotPublishEvent_whenOnlyAliasChanges() {
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).alias("old alias").build();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
dto.setAlias("new alias");
personService.updatePerson(id, dto);
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
}
@Test
void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() {
UUID id = UUID.randomUUID();
Person existing = Person.builder()
.id(id).firstName("Auguste").lastName("Raddatz")
.personType(PersonType.PERSON).notes("first note").build();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
dto.setNotes("revised note");
personService.updatePerson(id, dto);
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
}
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test

View File

@@ -98,7 +98,7 @@ class TranscriptionServiceTest {
return b;
});
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null, java.util.List.of());
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
@@ -168,7 +168,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null, java.util.List.of());
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
@@ -189,7 +189,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede", java.util.List.of());
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
@@ -208,7 +208,7 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
TranscriptionBlock result = transcriptionService.updateBlock(
docId, blockId, new UpdateTranscriptionBlockDTO("new", null), UUID.randomUUID());
docId, blockId, new UpdateTranscriptionBlockDTO("new", null, java.util.List.of()), UUID.randomUUID());
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
}
@@ -226,7 +226,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null, java.util.List.of()), UUID.randomUUID());
verify(senderModelService).checkAndTriggerTraining(senderId);
}
@@ -242,7 +242,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null, java.util.List.of()), UUID.randomUUID());
verify(senderModelService, never()).checkAndTriggerTraining(any());
}
@@ -477,7 +477,7 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null, java.util.List.of()), userId);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
@@ -502,8 +502,90 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null), userId);
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null, java.util.List.of()), userId);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
@Test
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(List.of(block1, block2));
}
@Test
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(any());
}
@Test
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(alreadyReviewed, unreviewed));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).isEmpty();
}
}

View File

@@ -0,0 +1,987 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stammbaum — Document Badge · Inline Pill Variant · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
/* ── Masthead ── */
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:60px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:740px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.tag{background:#012851;color:#A1DCD8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tag.amber{background:#7c4a00;color:#fde68a}
/* ── Section headers ── */
.sh{margin:0 0 28px}
.sh h2{font-size:16px;font-weight:900;color:#012851;letter-spacing:-.2px}
.sh p{font-size:12.5px;color:#666;max-width:720px;line-height:1.7;margin-top:5px}
.section{margin-bottom:80px;padding-bottom:80px;border-bottom:2px dashed #C8C4BE}
.section:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
/* ── Token tables ── */
.token-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.token-table{border-radius:6px;overflow:hidden}
.token-table.light{background:#fff;border:1px solid #E0DDD6}
.token-table.dark{background:#0F1923;border:1px solid #1E2D3D}
.token-head{padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;border-bottom:1px solid #E0DDD6}
.token-table.light .token-head{background:#F4F2EC;color:#888;border-bottom-color:#E0DDD6}
.token-table.dark .token-head{background:#0A1218;color:#4E6070;border-bottom-color:#1E2D3D}
.token-table table{width:100%;border-collapse:collapse;font-size:11px}
.token-table.light td{padding:6px 14px;border-bottom:1px solid #F0EEE8;vertical-align:middle}
.token-table.dark td{padding:6px 14px;border-bottom:1px solid #1A2830;vertical-align:middle;color:#8AAABB}
.token-table tr:last-child td{border-bottom:none}
.token-table.light td:first-child{font-size:9px;font-weight:700;color:#888;width:160px}
.token-table.dark td:first-child{font-size:9px;font-weight:700;color:#4E6070;width:160px}
.swatch{display:inline-block;width:12px;height:12px;border-radius:2px;vertical-align:middle;margin-right:6px}
.swatch.bordered{border:1px solid #DDD}
.warn{display:inline-block;background:#FEF3C7;color:#92400E;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
.pass{display:inline-block;background:#D1FAE5;color:#065F46;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
/* ── Browser chrome ── */
.chrome{border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
.chrome.dark{background:#010e1e;border-color:#0d3358}
.chrome-bar{height:20px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0}
.chrome.dark .chrome-bar{background:#010a18;border-bottom-color:#0d3358}
.chrome-dot{width:6px;height:6px;border-radius:50%;background:#BDB8B1}
.chrome.dark .chrome-dot{background:#1a2a3a}
.chrome-url{flex:1;height:9px;background:#CCC8C2;border-radius:5px;margin-left:6px}
.chrome.dark .chrome-url{background:#1a2a3a}
/* ── App nav ── */
.app-nav{height:34px;background:#012851;border-top:4px solid #A1DCD8;display:flex;align-items:center;padding:0 12px;gap:10px;flex-shrink:0}
.app-logo{font-family:'Tinos',Georgia,serif;font-size:7px;font-weight:700;color:#fff;letter-spacing:.5px}
.app-link{font-size:5.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.4);white-space:nowrap}
.app-link.on{color:rgba(255,255,255,.9)}
.app-nav-r{margin-left:auto;display:flex;gap:6px;align-items:center}
.app-av{width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5)}
/* ── Sub-header bar ── */
.sub-header{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:6px;flex-shrink:0}
.chrome.dark .sub-header{background:#011526;border-bottom-color:#0d3358}
.back-btn{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
.chrome.dark .back-btn{color:#8b97a5}
.sh-divider{width:1px;height:18px;background:#E4E2D7;flex-shrink:0;margin:0 4px}
.chrome.dark .sh-divider{background:#0d3358}
.sh-doc-title{font-family:'Tinos',Georgia,serif;font-size:10px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.chrome.dark .sh-doc-title{color:#f0efe9}
/* person chips in sub-header */
.sh-persons{display:flex;align-items:center;gap:5px;flex-shrink:0}
.sh-chip{display:flex;align-items:center;gap:4px}
.sh-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
.sh-name{font-size:8px;font-weight:600;color:#4b5563;white-space:nowrap}
.chrome.dark .sh-name{color:#9ca3af}
.sh-arrow{color:#A1DCD8;flex-shrink:0}
.chrome.dark .sh-arrow{color:#00c7b1}
/* INLINE PILL */
.pill{display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;margin-left:5px;vertical-align:middle;line-height:1.5}
.chrome.dark .pill{background:rgba(0,199,177,.10);border-color:#00c7b1;color:#f0efe9}
/* sub-header actions */
.sh-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;margin-left:8px}
.sh-btn-ghost{height:22px;padding:0 7px;border:1.5px solid #E4E2D7;border-radius:3px;font-size:6.5px;font-weight:700;color:#4b5563;display:flex;align-items:center;gap:3px;flex-shrink:0}
.chrome.dark .sh-btn-ghost{border-color:#0d3358;color:#8b97a5}
.sh-btn-primary{height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;gap:3px;flex-shrink:0}
.chrome.dark .sh-btn-primary{background:#A1DCD8;color:#012851}
.sh-btn-icon{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
.chrome.dark .sh-btn-icon{border-color:#0d3358;color:#8b97a5}
/* ── Metadata drawer ── */
.meta-drawer{background:#ffffff;border-bottom:1px solid #E4E2D7;padding:14px 16px;flex-shrink:0}
.chrome.dark .meta-drawer{background:#011526;border-bottom-color:#0d3358}
.meta-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
.meta-col-head{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
.chrome.dark .meta-col-head{color:#8b97a5}
.meta-field{margin-bottom:8px}
.meta-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
.chrome.dark .meta-label{color:#8b97a5}
.meta-value{font-family:'Tinos',Georgia,serif;font-size:10px;color:#012851}
.chrome.dark .meta-value{color:#f0efe9}
/* ── Person card in metadata ── */
.person-card{display:flex;align-items:center;gap:5px;padding:3px 5px;border-radius:3px}
.p-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6.5px;font-weight:800;color:#fff;flex-shrink:0}
.p-name{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851}
.chrome.dark .p-name{color:#f0efe9}
/* ── PDF placeholder ── */
.pdf-area{background:#d4d0c8;flex:1;display:flex;align-items:center;justify-content:center;min-height:80px}
.chrome.dark .pdf-area{background:#010e1e}
.paper{background:#FFFEF8;width:40%;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:8px 10px;display:flex;flex-direction:column;gap:2px}
.chrome.dark .paper{background:#0d1820}
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px}
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px}
.chrome.dark .pl,.chrome.dark .ps{background:#1E2D3D}
/* ── Side-by-side layout ── */
.split-screens{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
.screen-lbl{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:8px;display:flex;align-items:center;gap:5px}
.lbl-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.cap{font-size:10px;color:#999;font-style:italic;line-height:1.6;margin-top:10px;max-width:460px}
/* ── Edge-case cards ── */
.edge-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:12px}
.edge-card{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
.edge-head{background:#F4F2EC;padding:8px 12px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;border-bottom:1px solid #E0DDD6}
.edge-body{padding:10px 12px}
.edge-note{font-size:10.5px;color:#555;line-height:1.65;margin-top:8px}
.no-badge{font-family:'Tinos',Georgia,serif;font-size:9px;color:#aaa;font-style:italic;padding:4px 5px}
/* ── Rules / implementation table ── */
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
.rules table{width:100%;border-collapse:collapse}
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
.rules td{font-size:11px;color:#444;padding:8px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.6}
.rules tr:last-child td{border-bottom:none}
.rules td:first-child{font-size:9px;font-weight:700;color:#012851;white-space:nowrap;width:200px}
.rules td code{font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px;color:#555;white-space:nowrap}
/* ── Pill anatomy callout ── */
.pill-anatomy{display:flex;align-items:center;gap:20px;background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:18px 24px;margin-bottom:16px;flex-wrap:wrap}
.pill-demo-light{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f9f8f4;border-radius:4px}
.pill-demo-dark{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#011526;border-radius:4px}
.pill-annotation{font-size:9.5px;color:#888;line-height:1.7}
.pill-annotation strong{color:#012851;font-weight:700}
/* ── Responsive preview containers ── */
.responsive-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
.responsive-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-bottom:16px}
/* ── Tablet sub-header ── */
.sub-header-tablet{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0}
.sh-title-truncated{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.sh-overflow-btn{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:9px;font-weight:700;flex-shrink:0}
.meta-stacked{padding:12px 14px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:9px}
.meta-stacked .meta-label{font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
.meta-stacked .meta-value{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851;margin-bottom:10px}
.meta-stacked .meta-section-head{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
/* ── Mobile sub-header ── */
.sub-header-mobile{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0}
.sh-title-mobile{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.meta-mobile{padding:10px 12px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:8.5px}
.meta-mobile .m-label{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:2px;margin-top:8px}
.meta-mobile .m-label:first-child{margin-top:0}
.meta-mobile .m-value{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;margin-bottom:2px}
.person-row-mobile{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}
.person-row-mobile .p-av-sm{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
.person-row-mobile .p-nm{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;white-space:nowrap}
</style>
</head>
<body>
<div class="doc">
<!-- ══ MASTHEAD ══════════════════════════════════════════════════════════════ -->
<div class="mh">
<h1>Stammbaum — Document Badge · Inline Pill Variant</h1>
<p>
Design spec for the inline relationship pill on the Document Detail page. Relationship labels appear
as <strong>inline pills directly next to each person's name</strong> — both in the 48 px sub-header bar
and in the Personen column of the 3-column metadata drawer. Example: Karl Raddatz
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">ELTERNTEIL</span>
→ Hans Raddatz
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">KIND</span>.
This is View 2 of 3 in the Stammbaum document-badge feature set.
</p>
<div class="byline">Familienarchiv · 2026-04-27 · Leonie Voss, UX Lead</div>
<div class="tag-row">
<span class="tag">Stammbaum Feature</span>
<span class="tag">View 2 of 3 — Document Badge</span>
<span class="tag">Inline Pill Variant</span>
<span class="tag">Desktop / Tablet / Mobile</span>
<span class="tag">Light + Dark</span>
</div>
</div>
<!-- ══ SECTION 1 — DESIGN TOKENS ════════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>1 · Design tokens</h2>
<p>All colour values used by the inline pill and its surrounding context. Light and dark themes are shown side by side. Contrast ratios are against the respective surface colour.</p>
</div>
<!-- Pill anatomy callout -->
<div class="pill-anatomy">
<div class="pill-demo-light">
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#012851;font-weight:700">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle">ELTERNTEIL</span>
</div>
<div class="pill-demo-dark">
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#f0efe9;font-weight:700">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle">ELTERNTEIL</span>
</div>
<div class="pill-annotation">
<strong>Pill anatomy</strong><br>
border-radius: 9999px &nbsp;·&nbsp; padding: 1px 8px<br>
font: Montserrat 9px 700 uppercase letter-spacing .07em<br>
margin-left: 8px from name span &nbsp;·&nbsp; vertical-align: middle
</div>
</div>
<div class="token-grid">
<!-- Light -->
<div class="token-table light">
<div class="token-head">Light theme — surface #ffffff</div>
<table>
<tr>
<td>Pill bg</td>
<td><span class="swatch bordered" style="background:rgba(161,220,216,.25)"></span>rgba(161,220,216,.25) — near-white on white<span class="pass">~14:1 AAA ✓ (text on near-white)</span></td>
</tr>
<tr>
<td>Pill border</td>
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — mint accent outline</td>
</tr>
<tr>
<td>Pill text</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — navy ink<span class="pass">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Person name</td>
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 — Montserrat 11px (sub-header)</td>
</tr>
<tr>
<td>Meta person name</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 9.5px (metadata drawer)</td>
</tr>
<tr>
<td>Sub-header bg</td>
<td><span class="swatch bordered" style="background:#ffffff"></span>#ffffff</td>
</tr>
<tr>
<td>Sub-header border</td>
<td><span class="swatch" style="background:#e4e2d7"></span>#e4e2d7</td>
</tr>
<tr>
<td>Arrow (decorative)</td>
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — <code>aria-hidden</code><span class="warn">non-text only</span></td>
</tr>
<tr>
<td>Meta label</td>
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — Montserrat 9px 700 uppercase<span class="pass">4.8:1 AA ✓</span></td>
</tr>
<tr>
<td>Meta value</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 13px<span class="pass">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Doc title</td>
<td>Tinos serif 18px · #012851</td>
</tr>
<tr>
<td>Avatar KR</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — navy</td>
</tr>
<tr>
<td>Avatar HR</td>
<td><span class="swatch" style="background:#5a2d6f"></span>#5a2d6f — purple</td>
</tr>
</table>
</div>
<!-- Dark -->
<div class="token-table dark">
<div class="token-head">Dark theme — surface #011526</div>
<table>
<tr>
<td>Pill bg</td>
<td><span class="swatch bordered" style="background:rgba(0,199,177,.10);border-color:#0d3358"></span>rgba(0,199,177,.10) — dark teal wash<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">passes AA ✓</span></td>
</tr>
<tr>
<td>Pill border</td>
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — turquoise</td>
</tr>
<tr>
<td>Pill text</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — warm white<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Person name</td>
<td><span class="swatch" style="background:#9ca3af"></span>#9ca3af — (sub-header)</td>
</tr>
<tr>
<td>Meta person name</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — (metadata drawer)</td>
</tr>
<tr>
<td>Sub-header bg</td>
<td><span class="swatch" style="background:#011526;border:1px solid #0d3358"></span>#011526</td>
</tr>
<tr>
<td>Sub-header border</td>
<td><span class="swatch" style="background:#0d3358"></span>#0d3358</td>
</tr>
<tr>
<td>Arrow (decorative)</td>
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — <code>aria-hidden</code><span class="warn" style="background:rgba(254,243,199,.1);color:#FDE68A;border:none">non-text only</span></td>
</tr>
<tr>
<td>Meta label</td>
<td><span class="swatch" style="background:#8b97a5"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">7.1:1 AAA ✓</span></td>
</tr>
<tr>
<td>Meta value</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Doc title</td>
<td>Tinos serif 18px · #f0efe9</td>
</tr>
</table>
</div>
</div>
<p style="font-size:10.5px;color:#888;font-style:italic;margin-top:6px">
⚠ Pill background rgba(161,220,216,.25) is nearly transparent on white — the effective contrast for the text is calculated against the near-white composite, yielding ~14:1.
The arrow between sender and receiver chips in the sub-header is <code>aria-hidden="true"</code> — directional meaning is conveyed by DOM order (sender before receiver) and the visual left-to-right reading order.
</p>
</div>
<!-- ══ SECTION 2 — DESKTOP LIGHT & DARK ═════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>2 · Desktop (1280 px) — light &amp; dark</h2>
<p>
Full document detail page at ~65% scale. Sub-header bar (48 px) shows inline pills next to avatar chips.
Metadata drawer is open, showing pills next to person names in the Personen column.
Both light and dark themes shown side by side.
</p>
</div>
<div class="split-screens">
<!-- ── LIGHT ── -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Light theme</div>
<div class="chrome">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<!-- App header -->
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Stammbaum</div>
<div class="app-link">Admin</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Sub-header -->
<div class="sub-header">
<div class="back-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="sh-divider"></div>
<div class="sh-doc-title">W-0311 · Divacca</div>
<div class="sh-persons">
<!-- Sender chip + pill -->
<div class="sh-chip">
<div class="sh-av" style="background:#012851">KR</div>
<span class="sh-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
<!-- Arrow -->
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
<!-- Receiver chip + pill -->
<div class="sh-chip">
<div class="sh-av" style="background:#5a2d6f">HR</div>
<span class="sh-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
<div class="sh-actions">
<div class="sh-btn-ghost">Details ▾</div>
<div class="sh-btn-ghost">Transkribieren</div>
<div class="sh-btn-primary">Bearbeiten</div>
<div class="sh-btn-icon">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</div>
</div>
</div>
<!-- Metadata drawer -->
<div class="meta-drawer">
<div class="meta-grid">
<!-- Col 1: Details -->
<div>
<div class="meta-col-head">Details</div>
<div class="meta-field">
<div class="meta-label">Datum</div>
<div class="meta-value"></div>
</div>
<div class="meta-field">
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
</div>
<div class="meta-field">
<div class="meta-label">Status</div>
<div class="meta-value">Hochgeladen</div>
</div>
</div>
<!-- Col 2: Personen with inline pills -->
<div>
<div class="meta-col-head">Personen</div>
<div class="meta-field">
<div class="meta-label">Absender</div>
<div class="person-card">
<div class="p-av" style="background:#012851">KR</div>
<span class="p-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label">Empfänger</div>
<div class="person-card">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span class="p-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
</div>
<!-- Col 3: Tags -->
<div>
<div class="meta-col-head">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
</div>
</div>
</div>
</div>
<!-- PDF area -->
<div class="pdf-area">
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
</div>
</div>
<p class="cap">Light. Pills appear in both the sub-header chip row and the metadata Personen column. Arrow between chips is mint-coloured and aria-hidden.</p>
</div>
<!-- ── DARK ── -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Dark theme</div>
<div class="chrome dark">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Stammbaum</div>
<div class="app-link">Admin</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div class="sub-header">
<div class="back-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="sh-divider"></div>
<div class="sh-doc-title">W-0311 · Divacca</div>
<div class="sh-persons">
<div class="sh-chip">
<div class="sh-av" style="background:#012851">KR</div>
<span class="sh-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
<div class="sh-chip">
<div class="sh-av" style="background:#5a2d6f">HR</div>
<span class="sh-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
<div class="sh-actions">
<div class="sh-btn-ghost">Details ▾</div>
<div class="sh-btn-ghost">Transkribieren</div>
<div class="sh-btn-primary">Bearbeiten</div>
<div class="sh-btn-icon">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</div>
</div>
</div>
<div class="meta-drawer">
<div class="meta-grid">
<div>
<div class="meta-col-head">Details</div>
<div class="meta-field">
<div class="meta-label">Datum</div>
<div class="meta-value"></div>
</div>
<div class="meta-field">
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
</div>
<div class="meta-field">
<div class="meta-label">Status</div>
<div class="meta-value">Hochgeladen</div>
</div>
</div>
<div>
<div class="meta-col-head">Personen</div>
<div class="meta-field">
<div class="meta-label">Absender</div>
<div class="person-card">
<div class="p-av" style="background:#012851">KR</div>
<span class="p-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label">Empfänger</div>
<div class="person-card">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span class="p-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
</div>
<div>
<div class="meta-col-head">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Berlin</span>
</div>
</div>
</div>
</div>
<div class="pdf-area">
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
</div>
</div>
<p class="cap">Dark. Pills flip to rgba(0,199,177,.10) bg, #00c7b1 border, #f0efe9 text. Sub-header and metadata surfaces both use #011526.</p>
</div>
</div>
</div>
<!-- ══ SECTION 3 — TABLET (768 px) ══════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>3 · Tablet (768 px)</h2>
<p>
The 3-column metadata grid collapses to a single stacked column. The sub-header truncates the document
title and moves secondary actions behind a "…" overflow button. Pills remain inline next to person names in both locations.
</p>
</div>
<div class="responsive-grid">
<!-- Tablet light -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Tablet · 768 px · Light</div>
<div class="chrome" style="max-width:400px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Tablet sub-header: back + title truncated + overflow -->
<div class="sub-header-tablet">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
<div class="sh-title-truncated">W-0311 · Divacca</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<div class="sh-btn-primary" style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center">Bearbeiten</div>
<div class="sh-overflow-btn">···</div>
</div>
</div>
<!-- Stacked metadata — Personen section with pills -->
<div class="meta-stacked">
<div class="meta-section-head">Personen</div>
<div style="margin-bottom:6px">
<div class="meta-label">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851;width:18px;height:18px;font-size:6px">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
</div>
</div>
<div style="margin-bottom:10px">
<div class="meta-label">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#5a2d6f;width:18px;height:18px;font-size:6px">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KIND</span>
</div>
</div>
<div class="meta-section-head">Details</div>
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
<div class="meta-label" style="margin-top:6px">Status</div>
<div class="meta-value">Hochgeladen</div>
<div class="meta-label" style="margin-top:6px">Schlagwörter</div>
<div style="display:flex;gap:4px;margin-top:3px">
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
</div>
</div>
<p class="cap">Tablet light. 3-column metadata collapses to single column. Pills stay inline with names. Sub-header shows only title + primary action + overflow menu.</p>
</div>
<!-- Tablet dark -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Tablet · 768 px · Dark</div>
<div class="chrome dark" style="max-width:400px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div class="sub-header-tablet" style="background:#011526;border-bottom:1px solid #0d3358">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center">Bearbeiten</div>
<div style="width:22px;height:22px;border:1.5px solid #0d3358;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#8b97a5;font-size:9px;font-weight:700">···</div>
</div>
</div>
<div style="padding:12px 14px;background:#011526;border-bottom:1px solid #0d3358;font-size:9px">
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Personen</div>
<div style="margin-bottom:6px">
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
</div>
</div>
<div style="margin-bottom:10px">
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">KIND</span>
</div>
</div>
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Details</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Ort</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Status</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
<div style="display:flex;gap:4px;margin-top:3px">
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
</div>
</div>
<p class="cap">Tablet dark. Same collapse behaviour. Dark pill tokens apply throughout.</p>
</div>
</div>
</div>
<!-- ══ SECTION 4 — MOBILE (375 px) ══════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>4 · Mobile (375 px)</h2>
<p>
Sub-header is simplified to back arrow and document title only — no person chips in the bar.
Metadata is full-width single column. Each person row is <code>flex; align-items: center; flex-wrap: nowrap</code>
— avatar, name, and pill on one line. If the name is very long the row wraps gracefully before the pill.
Only primary action buttons are shown.
</p>
</div>
<div class="responsive-grid">
<!-- Mobile light -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Mobile · 375 px · Light</div>
<div class="chrome" style="max-width:260px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Mobile sub-header: back + title only -->
<div class="sub-header-mobile">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
<div class="sh-title-mobile">W-0311 · Divacca</div>
<div style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
</div>
<!-- Mobile metadata: full-width stacked -->
<div class="meta-mobile">
<div class="m-label">Absender</div>
<div class="person-row-mobile">
<div class="p-av-sm" style="background:#012851">KR</div>
<span class="p-nm">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
</div>
<div class="m-label">Empfänger</div>
<div class="person-row-mobile">
<div class="p-av-sm" style="background:#5a2d6f">HR</div>
<span class="p-nm">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
</div>
<div class="m-label">Ort</div>
<div class="m-value">Divacca</div>
<div class="m-label">Status</div>
<div class="m-value">Hochgeladen</div>
<div class="m-label">Schlagwörter</div>
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
</div>
</div>
<p class="cap">Mobile light. No chips in sub-header — only title + primary action. Person rows: avatar + name + pill, flex-wrap:nowrap. Pill text drops to 6px to fit.</p>
</div>
<!-- Mobile dark -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Mobile · 375 px · Dark</div>
<div class="chrome dark" style="max-width:260px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div style="height:48px;background:#011526;border-bottom:1px solid #0d3358;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0">
<div style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5;flex-shrink:0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
</div>
<div style="padding:10px 12px;background:#011526;border-bottom:1px solid #0d3358;font-size:8.5px">
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:2px">
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px;margin-top:6px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:8px">
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Ort</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Status</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
</div>
</div>
<p class="cap">Mobile dark. Pill tokens #00c7b1/#f0efe9 at reduced 6px font — still passes AA on dark surface.</p>
</div>
</div>
</div>
<!-- ══ SECTION 5 — EDGE CASES ════════════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>5 · Edge cases — when no pill is rendered</h2>
<p>Three cases where the pill is silently omitted. The person name renders as normal — no gap, no placeholder.</p>
</div>
<div class="edge-grid">
<!-- Edge 1: no family relationship -->
<div class="edge-card">
<div class="edge-head">No family relationship → no pill</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
<!-- no pill -->
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#888">ME</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Maria Engel</span>
<!-- no pill -->
</div>
</div>
<div class="no-badge">— no pill —</div>
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship === null</code> because the backend returns 404 (no kinship path). Name renders without trailing pill.</div>
</div>
</div>
<!-- Edge 2: social relationship (Kollegen) → pill shows label -->
<div class="edge-card">
<div class="edge-head">Social relationship (Kollegen) → pill shows label</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#3d6b5a">FW</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Fritz Weber</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
</div>
</div>
<div class="edge-note">Non-family relationships (Kollege, Freund, etc.) returned by the inference endpoint still render as pills. The pill component is label-agnostic — it renders whatever <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship</code> provides.</div>
</div>
</div>
<!-- Edge 3: multiple receivers → no pill -->
<div class="edge-card">
<div class="edge-head">Multiple receivers → no pill</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Hans Raddatz</span>
</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#6a7a52">ER</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Elfriede Raddatz</span>
</div>
</div>
<div class="no-badge">— no pill —</div>
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">receivers.length &gt; 1</code> — inference endpoint is never called, <code>inferredRelationship</code> is <code>null</code>. No pill on any person chip.</div>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 6 — IMPLEMENTATION REFERENCE TABLE ═══════════════════════════ -->
<div class="section">
<div class="sh">
<h2>6 · Implementation reference</h2>
<p>Exact CSS/Tailwind values for every element of the pill and its context. Use these as the ground truth during implementation review.</p>
</div>
<div class="rules">
<table>
<thead>
<tr>
<th>Element</th>
<th>Tailwind / CSS</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Inline pill (light)</td>
<td><code>rounded-full border border-[#a1dcd8] bg-[rgba(161,220,216,.25)] px-2 py-0.5 text-[9px] font-bold uppercase tracking-[.07em] text-[#012851] ml-2 align-middle inline</code></td>
<td>Montserrat 9px 700. <code>ml-2</code> = 8px from name span. <code>vertical-align: middle</code> aligns cap-height to person name.</td>
</tr>
<tr>
<td>Inline pill (dark)</td>
<td><code>dark:bg-[rgba(0,199,177,.10)] dark:border-[#00c7b1] dark:text-[#f0efe9]</code></td>
<td>All three dark overrides applied together. Rest of pill class unchanged.</td>
</tr>
<tr>
<td>Person name span</td>
<td><code>font-sans text-[11px] text-[#4b5563] dark:text-[#9ca3af]</code> (sub-header) or <code>font-serif text-[9.5px] text-ink dark:text-[#f0efe9]</code> (metadata)</td>
<td>Name and pill share a <code>flex items-center gap-0</code> wrapper. Pill is the immediate next sibling of the name <code>&lt;span&gt;</code>.</td>
</tr>
<tr>
<td>Sub-header chip area</td>
<td><code>flex items-center gap-1.5</code></td>
<td>Wraps one sender chip + arrow + one receiver chip. Placed after the doc-title block, before action buttons.</td>
</tr>
<tr>
<td>Chip (avatar + name + pill)</td>
<td><code>flex items-center gap-1</code></td>
<td>Avatar, name span, and pill as three siblings inside the chip div.</td>
</tr>
<tr>
<td>Arrow between chips (sub-header)</td>
<td><code>h-2.5 w-2.5 shrink-0 text-[#a1dcd8] dark:text-[#00c7b1]</code> with <code>aria-hidden="true"</code></td>
<td>Arrow SVG carries no semantic information. DOM order (sender chip before receiver chip) conveys direction for assistive technology.</td>
</tr>
<tr>
<td>Person avatar (sub-header)</td>
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6px] font-bold text-white shrink-0</code></td>
<td>20×20 px. Initials in 6px bold white. Background colour is person-specific (set inline).</td>
</tr>
<tr>
<td>Person avatar (metadata)</td>
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6.5px] font-extrabold text-white shrink-0</code></td>
<td>Same 20×20 px. Slightly heavier weight (800) to match existing drawer card style.</td>
</tr>
<tr>
<td>Pill condition</td>
<td><code>{#if inferredRelationship} … {/if}</code> wraps both the sender pill and the receiver pill</td>
<td>Render only when <code>inferredRelationship !== null &amp;&amp; receivers.length === 1</code>. The check lives in <code>+page.server.ts</code>, not in the component.</td>
</tr>
<tr>
<td>Pill label value</td>
<td><code>inferredRelationship.labelFromA</code> next to sender, <code>inferredRelationship.labelFromB</code> next to receiver</td>
<td>Labels are pre-translated strings from the backend. No frontend i18n key needed for the label text itself.</td>
</tr>
<tr>
<td>Mobile person row</td>
<td><code>flex items-center gap-1 flex-nowrap</code></td>
<td><code>flex-wrap: nowrap</code> keeps avatar + name + pill on one line. If name overflows container, truncate name with <code>truncate</code>, never truncate the pill.</td>
</tr>
<tr>
<td>Mobile pill font-size</td>
<td><code>text-[6px]</code> at ≤375 px</td>
<td>Reduced from 9px (desktop) to 6px on mobile to fit without overflow. Contrast still passes AA at 6px bold.</td>
</tr>
<tr>
<td>Sub-header at mobile</td>
<td>Chips removed entirely from sub-header at <code>max-width: 767px</code></td>
<td>Sub-header shows only back arrow + document title + primary action button. Person chips with pills appear only in the metadata section on mobile.</td>
</tr>
</tbody>
</table>
</div>
<p style="font-size:10.5px;color:#888;margin-top:12px;line-height:1.7">
<strong style="color:#012851">Accessibility note:</strong> The pill text ("ELTERNTEIL", "KIND") is uppercase visually but the accessible name should be the mixed-case label from the backend (<code>labelFromA</code>). Apply <code>aria-label={labelFromA}</code> on the pill span so screen readers announce "Elternteil" not "E-L-T-E-R-N-T-E-I-L". The visual uppercase is achieved with CSS <code>text-transform: uppercase</code>, not by changing the source string.
</p>
</div>
</div><!-- /doc -->
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

197
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1,197 @@
# Frontend — Familienarchiv
## Overview
SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows.
## Tech Stack
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
- **Language**: TypeScript 5.9
- **Styling**: Tailwind CSS 4.1 + custom brand utilities
- **Build Tool**: Vite 7
- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static)
- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish
- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec)
- **PDF Rendering**: `pdfjs-dist` (PDF.js)
- **Testing**:
- Unit/Server: Vitest 4 (Node environment)
- Component: Vitest Browser Mode with Playwright (Chromium)
- E2E: Playwright (`frontend/e2e/`)
## Project Structure
```
src/
├── routes/ # SvelteKit file-based routing
│ ├── +layout.svelte # Global layout: header, nav, auth state
│ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── persons/ # Person directory, detail, edit, merge
│ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── chronik/ # Unified activity feed
│ ├── admin/ # User, group, tag, OCR, system management
│ ├── api/ # Internal API proxies (server-side only)
│ ├── login/ logout/ # Auth pages
│ └── ...
├── lib/
│ ├── components/ # Reusable Svelte components
│ │ ├── document/ # Document-specific components
│ │ ├── chronik/ # Activity feed components
│ │ └── user/ # User-related components
│ ├── generated/ # Auto-generated API types (openapi-typescript)
│ ├── server/ # Server-only utilities (db, auth helpers)
│ ├── services/ # Client-side service logic
│ ├── stores/ # Svelte stores (global state)
│ ├── types.ts # Shared TypeScript types
│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode)
│ ├── api.server.ts # Typed API client factory
│ ├── utils.ts # Shared utilities
│ ├── relativeTime.ts # Time formatting
│ ├── search.ts # Search utilities
│ └── paraglide/ # Generated i18n code
├── hooks/ # SvelteKit hooks (handle, handleFetch)
└── actions/ # Custom Svelte actions (click outside, etc.)
```
## API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`:
```typescript
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
- Use `result.data!` after an ok check
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
## Form Actions Pattern
```typescript
// +page.server.ts
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
// ...
return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success
}
};
```
## Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
```typescript
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date(doc.documentDate + 'T12:00:00')
);
```
## Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`):
| Class | Value | Usage |
| ------------ | --------- | -------------------------------- |
| `brand-navy` | `#002850` | Primary text, buttons, headers |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders |
Typography:
- `font-serif` (Merriweather) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
<!-- content -->
</div>
```
## Key UI Components
| Component | Props | Description |
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead |
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay |
| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block |
| `DocumentTopBar` | `document` | Responsive document metadata header |
## How to Run
### Development
```bash
cd frontend
npm install
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
```
### Build & Preview
```bash
npm run build # Production build
npm run preview # Preview production build
```
### Code Quality
```bash
npm run lint # Prettier + ESLint check
npm run format # Auto-fix formatting
npm run check # svelte-check (type checking)
```
### Testing
```bash
npm run test # Vitest unit + server tests (headless)
npm run test:coverage # Coverage report (server project only)
npm run test:e2e # Playwright E2E tests
npm run test:e2e:headed # Playwright E2E with visible browser
npm run test:e2e:ui # Playwright UI mode
```
### Regenerate API Types
Requires backend running with `--spring.profiles.active=dev`:
```bash
npm run generate:api
```
## Vite Proxy
During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`).
## i18n (Paraglide)
Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with:
```bash
npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
```
Or let the Vite plugin handle it automatically during dev/build.

141
frontend/e2e/CLAUDE.md Normal file
View File

@@ -0,0 +1,141 @@
# E2E Tests — Familienarchiv
## Overview
End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO).
## Tech Stack
- **Test Runner**: Playwright (`@playwright/test`)
- **Browser**: Chromium (desktop)
- **Locale**: `de-DE` (ensures German language detection)
- **Auth**: Shared session cookie stored after setup
## Project Structure
```
frontend/e2e/
├── auth.setup.ts # Authentication setup — logs in and saves session
├── auth.spec.ts # Authentication flows (login, logout, register)
├── admin.spec.ts # Admin panel CRUD operations
├── annotations.spec.ts # Document annotation features
├── bottom-panel.spec.ts # Bottom panel / transcription panel
├── dashboard-*.spec.ts # Dashboard variants and screenshots
├── documents.spec.ts # Document upload, edit, search
├── focus-rings.spec.ts # Accessibility focus ring tests
├── header.spec.ts # Navigation header
├── history.spec.ts # Chronik / activity feed
├── korrespondenz.spec.ts # Correspondence timeline
├── lang.spec.ts # Language switching
├── password-reset.spec.ts # Password reset flow
├── permissions.spec.ts # Role-based access control
├── persons.spec.ts # Person directory CRUD
├── profile.spec.ts # User profile
├── theme.spec.ts # Dark/light mode
├── transcription.spec.ts # Transcription workflows
├── accessibility.spec.ts # Axe accessibility scans
├── fixtures/ # Test data fixtures
└── helpers/ # Test helper utilities
```
## Authentication Strategy
Tests share auth state via a stored session cookie:
1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json`
2. **Tests**: All test projects depend on `setup` and reuse the stored session
This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`).
## Configuration
Config lives in `frontend/playwright.config.ts`:
| Setting | Value | Notes |
| --------------- | ----------------------- | ------------------------------ |
| `testDir` | `./e2e` | Test file location |
| `fullyParallel` | `false` | Shared auth state |
| `workers` | `1` | Sequential execution |
| `screenshot` | `'on'` | Always capture |
| `video` | `'retain-on-failure'` | Keep on failure |
| `trace` | `'retain-on-failure'` | Keep on failure |
| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` |
The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL.
## How to Run
### Prerequisites
The full stack must be running (or the `webServer` config will start the frontend dev server):
```bash
# Start infrastructure
docker-compose up -d
# Ensure backend is healthy
curl http://localhost:8080/actuator/health
```
### Run E2E Tests
```bash
cd frontend
# Headless (CI mode)
npm run test:e2e
# With visible browser
npm run test:e2e:headed
# Interactive UI mode
npm run test:e2e:ui
# Run a specific test file
npx playwright test documents.spec.ts
# Run with a different base URL (e.g., docker frontend on 5173)
E2E_BASE_URL=http://localhost:5173 npx playwright test
```
## Writing New E2E Tests
1. Create a new `.spec.ts` file in `frontend/e2e/`
2. Use the shared auth state (no manual login needed)
3. Use page object patterns or helper functions from `helpers/`
4. Add `test-data-id` attributes to components for stable selectors
5. Run with `--debug` or `--ui` to troubleshoot
### Example Test Pattern
```typescript
import { test, expect } from '@playwright/test';
test('user can create a document', async ({ page }) => {
await page.goto('/documents/new');
await page.getByTestId('document-title').fill('Test Document');
await page.getByTestId('save-button').click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
});
```
## Accessibility Testing
`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test.
```bash
npx playwright test accessibility.spec.ts
```
## Troubleshooting
| Issue | Solution |
| --------------------- | ---------------------------------------- |
| Auth failures | Delete `e2e/.auth/user.json` and re-run |
| Backend not reachable | Ensure `docker-compose up -d` is running |
| Flaky tests | Increase timeout or add explicit waits |
| Screenshots missing | Check `test-results/e2e/` |
## CI Integration
E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first.

View File

@@ -0,0 +1,202 @@
/**
* E2E regression tests for PersonTypeahead dropdown visibility.
*
* These tests verify that the dropdown list is never clipped by a parent
* container's stacking context — the root cause of issue #343.
*
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
* as required by the acceptance criteria.
*/
import { test, expect, type Page } from '@playwright/test';
/**
* Find a document edit URL to use as the test page.
* Falls back to /documents/new if no existing document is found.
*/
async function getDocumentEditUrl(page: Page): Promise<string> {
await page.goto('/');
await page.waitForLoadState('networkidle');
const firstDocLink = page.locator('a[href^="/documents/"]').first();
const href = await firstDocLink.getAttribute('href').catch(() => null);
if (href) {
return `${href}/edit`;
}
return '/documents/new';
}
/** Wait for the listbox to become visible after triggering a search. */
async function waitForListbox(page: Page): Promise<void> {
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
}
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
// Find the sender typeahead input (the visible text input, not the hidden one)
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
// Type to trigger the dropdown
await senderInput.click();
await senderInput.fill('a');
// Wait for the dropdown to appear (handles debounce automatically)
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
// Verify the bounding box is within the viewport (not clipped)
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(720);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
});
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
page
}) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
const inputBox = await senderInput.boundingBox();
expect(inputBox).not.toBeNull();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const dropdownBox = await dropdown.boundingBox();
expect(dropdownBox).not.toBeNull();
// Dropdown must appear below the input, not on top or clipped behind it
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
});
});
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(1024);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
});
});
test.describe('PersonTypeahead — keyboard navigation', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('ArrowDown moves focus to the first option', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
await senderInput.press('ArrowDown');
// First option should now be the active descendant
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
expect(activeDescendant).toBeTruthy();
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
});
test('Escape key closes the dropdown', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
await senderInput.press('Escape');
await expect(dropdown).not.toBeVisible();
});
test('aria-expanded is true when dropdown is open', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
// Initially closed
const initialExpanded = await senderInput.getAttribute('aria-expanded');
expect(initialExpanded).toBe('false');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const expanded = await senderInput.getAttribute('aria-expanded');
expect(expanded).toBe('true');
});
});
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
// Click somewhere else on the page
await page.click('body', { position: { x: 10, y: 10 } });
await expect(dropdown).not.toBeVisible();
});
});

View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
// Tests skipped until Playwright Chromium is installed in CI — see issue #363.
test.describe('Stammbaum — issue #358', () => {
test.skip();
test('nav swap: /briefwechsel still renders without 404', async ({ page }) => {
// Plan journey 4: the /briefwechsel route must stay intact even though the
// AppNav now points at /stammbaum.
const response = await page.goto('/briefwechsel');
expect(response?.status()).toBeLessThan(400);
await expect(page).toHaveURL(/\/briefwechsel/);
});
test('/stammbaum renders the page heading', async ({ page }) => {
await page.goto('/stammbaum');
await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
});
test('/stammbaum either shows an empty state or at least one node', async ({ page }) => {
// Plan journey 3 (empty branch) and journey 1 (populated branch) covered jointly:
// the test passes whenever the page renders one of the two coherent states.
await page.goto('/stammbaum');
const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
const anyNode = page.locator('svg[role="img"][aria-label="Stammbaum"] g[role="button"]');
await expect(async () => {
const emptyVisible = await empty.isVisible().catch(() => false);
const nodeCount = await anyNode.count();
expect(emptyVisible || nodeCount > 0).toBe(true);
}).toPass();
if (await empty.isVisible().catch(() => false)) {
await expect(page.getByRole('link', { name: /Zur Personenliste/ })).toBeVisible();
}
});
test('person edit Stammbaum card surfaces the year-range error', async ({ page }) => {
// Plan task 36: Bis < Von triggers the inline error and keeps the form unsubmitted.
// We pick the first person, open the edit page, expand the add-rel form, and
// inspect the validation message bound to the Bis field.
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await expect(page).toHaveURL(/\/persons\/[^/]+/);
await page.goto(page.url() + '/edit');
// Open the add-rel form
const addBtn = page.getByRole('button', { name: /Beziehung hinzufügen/i });
await addBtn.click();
// Enter Von 1935, Bis 1920 → expect the year-range error
const fromInput = page.locator('input[name="fromYear"]');
const toInput = page.locator('input[name="toYear"]');
await fromInput.fill('1935');
await toInput.fill('1920');
await expect(page.locator('#add-rel-year-error')).toBeVisible();
await expect(page.locator('#add-rel-year-error')).toContainText(/Bis.*Von|nicht vor/i);
});
});

View File

@@ -40,6 +40,26 @@ export default defineConfig(
parser: ts.parser,
svelteConfig
}
},
rules: {
// text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail).
// layout.css documents it as decorative-only (borders, icon tints, bg fills).
// For any text label use text-primary or text-ink instead. This rule catches
// the pattern where text-accent appears inside a JavaScript string literal
// (e.g. conditional ternary class expressions in Svelte templates).
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/\\btext-accent\\b/]',
message:
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
},
{
selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]',
message:
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
}
]
}
}
);

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Briefwechsel",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen",
@@ -816,6 +818,7 @@
"pagination_next": "Weiter",
"pagination_page_of": "Seite {page} von {total}",
"pagination_nav_label": "Seitennavigation",
"pagination_page_button": "Seite {page}",
"common_opens_new_tab": "(öffnet in neuem Tab)",
@@ -904,5 +907,80 @@
"bulk_edit_loading": "Dokumente werden geladen…",
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
"bulk_edit_topbar_title": "Massenbearbeitung",
"bulk_edit_count_pill": "{count} werden bearbeitet"
"bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum",
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
"relation_parent_of": "Elternteil von",
"relation_child_of": "Kind von",
"relation_spouse_of": "Ehegatte",
"relation_sibling_of": "Geschwister",
"relation_friend": "Freund",
"relation_colleague": "Kollege",
"relation_employer": "Arbeitgeber",
"relation_doctor": "Arzt",
"relation_neighbor": "Nachbar",
"relation_other": "Sonstige",
"relation_inferred_parent": "Elternteil",
"relation_inferred_child": "Kind",
"relation_inferred_spouse": "Ehegatte",
"relation_inferred_sibling": "Geschwister",
"relation_inferred_grandparent": "Großelternteil",
"relation_inferred_grandchild": "Enkelkind",
"relation_inferred_great_grandparent": "Urgroßelternteil",
"relation_inferred_great_grandchild": "Urenkel",
"relation_inferred_uncle_aunt": "Onkel/Tante",
"relation_inferred_niece_nephew": "Nichte/Neffe",
"relation_inferred_great_uncle_aunt": "Großonkel/Großtante",
"relation_inferred_great_niece_nephew": "Großnichte/Großneffe",
"relation_inferred_inlaw_parent": "Schwiegerelternteil",
"relation_inferred_inlaw_child": "Schwiegerkind",
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
"relation_inferred_cousin_1": "Cousin/Cousine",
"relation_inferred_distant": "Weitläufige Verwandtschaft",
"doc_details_field_relationship": "Verwandtschaft",
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
"stammbaum_empty_link": "→ Zur Personenliste",
"stammbaum_panel_direct_rels": "Direkte Beziehungen",
"stammbaum_panel_derived_rels": "Abgeleitete Beziehungen",
"stammbaum_panel_to_person": "Zur Personenseite →",
"stammbaum_panel_add_rel": "+ Beziehung hinzufügen",
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
"stammbaum_zoom_in": "Vergrößern",
"stammbaum_zoom_out": "Verkleinern",
"stammbaum_generations": "Generationen",
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
"relation_year_from": "ab {year}",
"relation_year_to": "bis {year}",
"relation_year_error_bis_before_von": "Bis-Jahr darf nicht vor Von-Jahr liegen.",
"relation_label_family_member": "Als Familienmitglied",
"relation_toggle_add_to_tree": "Zum Stammbaum hinzufügen",
"relation_toggle_remove_from_tree": "Aus Stammbaum entfernen",
"relation_label_in_tree": "Erscheint im Stammbaum",
"relation_label_view_in_tree": "Ansehen →",
"relation_label_direct": "Direkte Beziehungen",
"relation_label_derived": "Abgeleitete Beziehungen",
"relation_btn_add": "Hinzufügen",
"relation_btn_save": "Speichern",
"relation_btn_cancel": "Abbrechen",
"relation_form_group_family": "Familie",
"relation_form_group_social": "Sozial",
"relation_form_field_type": "Typ",
"relation_form_field_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920",
"person_relationships_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt."
}

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Letters",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_confirm": "Confirm",
@@ -816,6 +818,7 @@
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {total}",
"pagination_nav_label": "Pagination",
"pagination_page_button": "Page {page}",
"common_opens_new_tab": "(opens in new tab)",
@@ -904,5 +907,80 @@
"bulk_edit_loading": "Loading documents…",
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
"bulk_edit_topbar_title": "Bulk edit",
"bulk_edit_count_pill": "{count} will be edited"
"bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree",
"error_relationship_not_found": "Relationship not found.",
"error_circular_relationship": "This relationship would form a cycle.",
"error_duplicate_relationship": "This relationship already exists.",
"relation_parent_of": "Parent of",
"relation_child_of": "Child of",
"relation_spouse_of": "Spouse",
"relation_sibling_of": "Sibling",
"relation_friend": "Friend",
"relation_colleague": "Colleague",
"relation_employer": "Employer",
"relation_doctor": "Doctor",
"relation_neighbor": "Neighbour",
"relation_other": "Other",
"relation_inferred_parent": "Parent",
"relation_inferred_child": "Child",
"relation_inferred_spouse": "Spouse",
"relation_inferred_sibling": "Sibling",
"relation_inferred_grandparent": "Grandparent",
"relation_inferred_grandchild": "Grandchild",
"relation_inferred_great_grandparent": "Great-grandparent",
"relation_inferred_great_grandchild": "Great-grandchild",
"relation_inferred_uncle_aunt": "Uncle/Aunt",
"relation_inferred_niece_nephew": "Niece/Nephew",
"relation_inferred_great_uncle_aunt": "Great-uncle/Aunt",
"relation_inferred_great_niece_nephew": "Great-niece/Nephew",
"relation_inferred_inlaw_parent": "Parent-in-law",
"relation_inferred_inlaw_child": "Child-in-law",
"relation_inferred_sibling_inlaw": "Sibling-in-law",
"relation_inferred_cousin_1": "Cousin",
"relation_inferred_distant": "Distant relative",
"doc_details_field_relationship": "Relationship",
"stammbaum_empty_heading": "No family members yet",
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
"stammbaum_empty_link": "→ Go to person list",
"stammbaum_panel_direct_rels": "Direct relationships",
"stammbaum_panel_derived_rels": "Derived relationships",
"stammbaum_panel_to_person": "Go to person page →",
"stammbaum_panel_add_rel": "+ Add relationship",
"stammbaum_relationships_heading": "Family tree & relationships",
"stammbaum_zoom_in": "Zoom in",
"stammbaum_zoom_out": "Zoom out",
"stammbaum_generations": "Generations",
"relation_error_duplicate": "This relationship already exists.",
"relation_error_circular": "This relationship would form a cycle.",
"relation_error_self": "A person cannot be related to themselves.",
"relation_year_from": "from {year}",
"relation_year_to": "until {year}",
"relation_year_error_bis_before_von": "End year must not precede start year.",
"relation_label_family_member": "Family member",
"relation_toggle_add_to_tree": "Add to family tree",
"relation_toggle_remove_from_tree": "Remove from family tree",
"relation_label_in_tree": "Appears in the family tree",
"relation_label_view_in_tree": "View →",
"relation_label_direct": "Direct relationships",
"relation_label_derived": "Derived relationships",
"relation_btn_add": "Add",
"relation_btn_save": "Save",
"relation_btn_cancel": "Cancel",
"relation_form_group_family": "Family",
"relation_form_group_social": "Social",
"relation_form_field_type": "Type",
"relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920",
"person_relationships_heading": "Relationships",
"person_relationships_empty": "No relationships known yet."
}

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Cartas",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_confirm": "Confirmar",
@@ -816,6 +818,7 @@
"pagination_next": "Siguiente",
"pagination_page_of": "Página {page} de {total}",
"pagination_nav_label": "Paginación",
"pagination_page_button": "Página {page}",
"common_opens_new_tab": "(abre en pestaña nueva)",
@@ -904,5 +907,80 @@
"bulk_edit_loading": "Cargando documentos…",
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
"bulk_edit_topbar_title": "Edición masiva",
"bulk_edit_count_pill": "Se editarán {count}"
"bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico",
"error_relationship_not_found": "La relación no fue encontrada.",
"error_circular_relationship": "Esta relación crearía un ciclo.",
"error_duplicate_relationship": "Esta relación ya existe.",
"relation_parent_of": "Progenitor de",
"relation_child_of": "Hijo/a de",
"relation_spouse_of": "Cónyuge",
"relation_sibling_of": "Hermano/a",
"relation_friend": "Amigo/a",
"relation_colleague": "Colega",
"relation_employer": "Empleador",
"relation_doctor": "Médico",
"relation_neighbor": "Vecino/a",
"relation_other": "Otro",
"relation_inferred_parent": "Progenitor",
"relation_inferred_child": "Hijo/a",
"relation_inferred_spouse": "Cónyuge",
"relation_inferred_sibling": "Hermano/a",
"relation_inferred_grandparent": "Abuelo/a",
"relation_inferred_grandchild": "Nieto/a",
"relation_inferred_great_grandparent": "Bisabuelo/a",
"relation_inferred_great_grandchild": "Bisnieto/a",
"relation_inferred_uncle_aunt": "Tío/Tía",
"relation_inferred_niece_nephew": "Sobrino/a",
"relation_inferred_great_uncle_aunt": "Tío/a abuelo/a",
"relation_inferred_great_niece_nephew": "Sobrino/a nieto/a",
"relation_inferred_inlaw_parent": "Suegro/a",
"relation_inferred_inlaw_child": "Yerno/Nuera",
"relation_inferred_sibling_inlaw": "Cuñado/a",
"relation_inferred_cousin_1": "Primo/a",
"relation_inferred_distant": "Pariente lejano",
"doc_details_field_relationship": "Parentesco",
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
"stammbaum_empty_link": "→ Ir a la lista de personas",
"stammbaum_panel_direct_rels": "Relaciones directas",
"stammbaum_panel_derived_rels": "Relaciones derivadas",
"stammbaum_panel_to_person": "Ir a la persona →",
"stammbaum_panel_add_rel": "+ Añadir relación",
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
"stammbaum_zoom_in": "Acercar",
"stammbaum_zoom_out": "Alejar",
"stammbaum_generations": "Generaciones",
"relation_error_duplicate": "Esta relación ya existe.",
"relation_error_circular": "Esta relación crearía un ciclo.",
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
"relation_year_from": "desde {year}",
"relation_year_to": "hasta {year}",
"relation_year_error_bis_before_von": "El año final no puede ser anterior al año inicial.",
"relation_label_family_member": "Miembro de la familia",
"relation_toggle_add_to_tree": "Añadir al árbol genealógico",
"relation_toggle_remove_from_tree": "Quitar del árbol genealógico",
"relation_label_in_tree": "Aparece en el árbol genealógico",
"relation_label_view_in_tree": "Ver →",
"relation_label_direct": "Relaciones directas",
"relation_label_derived": "Relaciones derivadas",
"relation_btn_add": "Añadir",
"relation_btn_save": "Guardar",
"relation_btn_cancel": "Cancelar",
"relation_form_group_family": "Familia",
"relation_form_group_social": "Social",
"relation_form_field_type": "Tipo",
"relation_form_field_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920",
"person_relationships_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones."
}

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = {
relatedPersonId: string;
relationType: RelationType;
fromYear?: number;
toYear?: number;
};
interface Props {
personId: string;
onSubmit?: (data: RelFormData) => Promise<void>;
}
let { personId, onSubmit }: Props = $props();
let open = $state(false);
let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state('');
let addRelatedPersonName = $state('');
let addFromYear = $state('');
let addToYear = $state('');
let callbackError = $state<string | null>(null);
const yearError = $derived.by(() => {
const from = addFromYear.trim();
const to = addToYear.trim();
if (!from || !to) return null;
const fromInt = parseInt(from, 10);
const toInt = parseInt(to, 10);
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
});
const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
);
const submitDisabled = $derived(
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
function reset() {
addType = 'PARENT_OF';
addRelatedPersonId = '';
addRelatedPersonName = '';
addFromYear = '';
addToYear = '';
callbackError = null;
}
function cancel() {
open = false;
reset();
}
async function handleCallbackSubmit(event: Event) {
event.preventDefault();
if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
const from = parseInt(addFromYear.trim(), 10);
if (!Number.isNaN(from)) data.fromYear = from;
const to = parseInt(addToYear.trim(), 10);
if (!Number.isNaN(to)) data.toYear = to;
try {
await onSubmit(data);
open = false;
reset();
} catch {
callbackError = m.error_internal_error();
}
}
</script>
{#snippet formFields()}
<div class="grid gap-3 md:grid-cols-2">
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_type()}</span>
<select
name="relationType"
bind:value={addType}
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"
>
<optgroup label={m.relation_form_group_family()}>
<option value="PARENT_OF">{m.relation_parent_of()}</option>
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
</optgroup>
<optgroup label={m.relation_form_group_social()}>
<option value="FRIEND">{m.relation_friend()}</option>
<option value="COLLEAGUE">{m.relation_colleague()}</option>
<option value="EMPLOYER">{m.relation_employer()}</option>
<option value="DOCTOR">{m.relation_doctor()}</option>
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
<option value="OTHER">{m.relation_other()}</option>
</optgroup>
</select>
</label>
<div>
<PersonTypeahead
name="relatedPersonId"
label="Person"
bind:value={addRelatedPersonId}
initialName={addRelatedPersonName}
excludePersonId={personId}
compact
/>
</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>
{#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if}
{#if callbackError}
<p class="mt-2 text-xs text-red-700" role="alert">{callbackError}</p>
{/if}
<div class="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onclick={cancel}
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
>
{m.relation_btn_cancel()}
</button>
<button
type="submit"
disabled={submitDisabled}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
>
{m.relation_btn_add()}
</button>
</div>
{/snippet}
{#if !open}
<button
type="button"
onclick={() => (open = true)}
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
>
{m.stammbaum_panel_add_rel()}
</button>
{:else if onSubmit}
<form onsubmit={handleCallbackSubmit} class="mt-3 rounded-sm border border-line bg-muted/40 p-3">
{@render formFields()}
</form>
{:else}
<form
method="POST"
action="?/addRelationship"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') {
open = false;
reset();
}
};
}}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
>
{@render formFields()}
</form>
{/if}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AddRelationshipForm from './AddRelationshipForm.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup);
describe('AddRelationshipForm', () => {
it('shows add-relationship button initially and no form', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
await expect.element(page.getByRole('button')).toBeInTheDocument();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('shows relationType select when add button is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
it('hides form and shows button when cancel is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
);
cancelBtn!.click();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('submit is disabled when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
});
it('form has no server action when onSubmit prop is provided', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const form = document.querySelector('form');
expect(form?.hasAttribute('action')).toBe(false);
});
it('shows year-range error when toYear is before fromYear', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935';
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
toInput.value = '1920';
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
await expect.element(page.getByRole('alert')).toBeVisible();
});
});

View File

@@ -18,7 +18,8 @@ let {
dimmed = false,
flashAnnotationId = null,
onDraw,
onAnnotationClick
onAnnotationClick,
onDeleteRequest
}: {
annotations: Annotation[];
canDraw: boolean;
@@ -29,6 +30,7 @@ let {
flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void;
onDeleteRequest?: (annotationId: string) => void;
} = $props();
let drawStart = $state<{ x: number; y: number } | null>(null);
@@ -112,6 +114,8 @@ const containerStyle = $derived(
dimmed={dimmed}
blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id}
showDelete={canDraw}
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
onclick={() => onAnnotationClick?.(annotation.id)}
onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)}

View File

@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
expect(el2.style.opacity).toBe('1');
});
it('does not show delete buttons (annotations owned by blocks)', async () => {
it('does not show delete button when annotation is not hovered or active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: true,
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when canDraw is false even if annotation is active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: false,
color: '#00C7B1',
activeAnnotationId: 'ann-1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
});

View File

@@ -11,6 +11,8 @@ let {
blockNumber = undefined,
isFlashing = false,
isResizable = false,
showDelete = false,
onDeleteRequest,
onclick,
onpointerenter,
onpointerleave
@@ -23,11 +25,15 @@ let {
blockNumber?: number | undefined;
isFlashing?: boolean;
isResizable?: boolean;
showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void;
onpointerenter: () => void;
onpointerleave: () => void;
} = $props();
const deleteVisible = $derived(showDelete && (isHovered || isActive));
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
onclick={onclick}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}}
onpointerenter={onpointerenter}
onpointerleave={onpointerleave}
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
{blockNumber}
</div>
{/if}
{#if deleteVisible}
<button
data-testid="annotation-delete-{annotation.id}"
type="button"
aria-label="Löschen"
onclick={(e) => {
e.stopPropagation();
onDeleteRequest?.();
}}
style="
position: absolute;
top: 4px;
right: 4px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--color-error, #e53e3e);
color: var(--color-error, #e53e3e);
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
z-index: 10;
"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{#if isResizable}
<AnnotationEditOverlay annotation={annotation} />
{/if}

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationShape from './AnnotationShape.svelte';
afterEach(cleanup);
function makeAnnotation(id = 'ann-1') {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#00C7B1',
createdAt: new Date().toISOString()
};
}
describe('AnnotationShape', () => {
it('renders the annotation element', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
});
it('does not show delete button when showDelete is false', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: false,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('shows delete button when showDelete is true and isHovered is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('shows delete button when showDelete is true and isActive is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('calls onDeleteRequest when delete button is clicked', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onclick when delete button is clicked', async () => {
const onclick = vi.fn();
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick,
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onclick).not.toHaveBeenCalled();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: false,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
@@ -14,9 +15,18 @@ type Props = {
sender: Person | null;
receivers: Person[];
tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
};
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
let {
documentDate,
location,
status,
sender,
receivers,
tags,
inferredRelationship = null
}: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
@@ -37,7 +47,7 @@ function getFullName(person: Person): string {
}
</script>
{#snippet personCard(person: Person)}
{#snippet personCard(person: Person, relationLabel: string | null = null)}
<a
href="/persons/{person.id}"
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
@@ -49,7 +59,10 @@ function getFullName(person: Person): string {
>
{getInitials(person.displayName)}
</span>
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
{#if relationLabel}
<RelationshipPill label={relationLabel} />
{/if}
</a>
{/snippet}
@@ -88,7 +101,7 @@ function getFullName(person: Person): string {
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_sender()}
</p>
{@render personCard(sender)}
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
</div>
{/if}
{#if receivers.length > 0}
@@ -97,8 +110,16 @@ function getFullName(person: Person): string {
{m.doc_details_field_receivers()}
</p>
<div class="space-y-0.5">
{#each displayedReceivers as receiver (receiver.id)}
{@render personCard(receiver)}
{#each displayedReceivers as receiver, i (receiver.id)}
{@render personCard(
receiver,
// Badge only shown when there is exactly one receiver — with multiple
// receivers the inferred label is computed from the sender's viewpoint
// and cannot be attributed to a specific receiver.
i === 0 && receivers.length === 1
? (inferredRelationship?.labelFromB ?? null)
: null
)}
{/each}
</div>
{#if hiddenReceiverCount > 0 && !showAllReceivers}

View File

@@ -81,6 +81,25 @@ describe('DocumentMetadataDrawer — persons column', () => {
renderDrawer({ sender: null, receivers: [] });
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
});
it('renders inferred relationship pills inline next to sender and receiver', async () => {
renderDrawer({
receivers: [receivers[0]],
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
});
// Sender link contains its pill, receiver link contains its pill.
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
await expect.element(senderLink).toBeInTheDocument();
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
await expect.element(receiverLink).toBeInTheDocument();
});
it('omits the pills when no inferred relationship is provided', async () => {
renderDrawer();
const elternteil = page.getByText('Elternteil');
expect(await elternteil.elements()).toHaveLength(0);
});
});
// ─── Tags column ─────────────────────────────────────────────────────────────

View File

@@ -30,9 +30,16 @@ type Props = {
canWrite: boolean;
fileUrl: string;
transcribeMode: boolean;
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
};
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
let {
doc,
canWrite,
fileUrl,
transcribeMode = $bindable(),
inferredRelationship = null
}: Props = $props();
let detailsOpen = $state(false);
@@ -275,6 +282,7 @@ let mobileMenuOpen = $state(false);
sender={doc.sender ?? null}
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
inferredRelationship={inferredRelationship}
/>
</div>
{/if}

View File

@@ -24,6 +24,7 @@ type Props = {
flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
};
let {
@@ -38,7 +39,8 @@ let {
annotationsDimmed = false,
flashAnnotationId = null,
onAnnotationClick,
onTranscriptionDraw
onTranscriptionDraw,
onDeleteAnnotationRequest
}: Props = $props();
</script>
@@ -98,6 +100,7 @@ let {
flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) {
}
}
const bellLabel = $derived(
stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()
);
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
@@ -72,12 +78,11 @@ onDestroy(() => {
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
aria-label={stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()}
aria-label={bellLabel}
title={bellLabel}
aria-expanded={open}
aria-haspopup="true"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
});
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];

View File

@@ -20,6 +20,48 @@ const controlBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
const activePageBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white';
/**
* Builds the sliding window of 1-indexed page numbers to render as buttons.
* Always shows: first, last, current, one neighbor each side.
* null entries represent ellipsis gaps.
*/
const pageWindow = $derived.by(() => {
const first = 1;
const last = totalPages;
const current = page + 1; // convert to 1-indexed
const windowStart = Math.max(first, current - 1);
const windowEnd = Math.min(last, current + 1);
const result: (number | null)[] = [];
result.push(first);
if (windowStart > first + 2) {
result.push(null); // left ellipsis
} else if (windowStart === first + 2) {
result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis
}
for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) {
result.push(p);
}
if (windowEnd < last - 2) {
result.push(null); // right ellipsis
} else if (windowEnd === last - 2) {
result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
}
if (last > first) {
result.push(last);
}
return result;
});
</script>
{#if totalPages > 1}
@@ -52,13 +94,60 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
</span>
{/if}
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
<span
data-testid="pagination-page-label"
aria-current="page"
class="font-sans text-sm text-ink-2"
aria-hidden="true"
class="font-sans text-sm text-ink-2 sm:hidden"
>
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Always in the AT tree: announces current page regardless of breakpoint.
On mobile, the desktop button container is display:none so this is the only AT anchor.
On desktop, the active page button also carries aria-current — both announce the same info. -->
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Desktop: numbered page buttons (hidden below sm:) -->
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
{#if entry === null}
{#if i === 1}
<span
data-testid="pagination-ellipsis-left"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{:else}
<span
data-testid="pagination-ellipsis-right"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{/if}
{:else if entry === page + 1}
<span
data-testid="pagination-page-{entry}"
aria-current="page"
aria-label={m.pagination_page_button({ page: entry })}
class={activePageBase}
>
{entry}
</span>
{:else}
<a
data-testid="pagination-page-{entry}"
aria-label={m.pagination_page_button({ page: entry })}
href={makeHref(entry - 1)}
class={linkBase}
>
{entry}
</a>
{/if}
{/each}
</div>
{#if hasNext}
<a

View File

@@ -19,11 +19,145 @@ describe('Pagination', () => {
await expect.element(label).toHaveTextContent(/10/);
});
it('marks the current page label with aria-current="page"', async () => {
it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-current', 'page');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
describe('page number buttons', () => {
it('renders page number buttons when totalPages > 1', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
// active page button — the current page (5, 1-indexed)
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toBeInTheDocument();
});
it('does not render page number buttons when totalPages <= 1', async () => {
render(Pagination, { page: 0, totalPages: 1, makeHref });
// entire nav is hidden
const nav = page.getByRole('navigation');
await expect.element(nav).not.toBeInTheDocument();
});
it('marks the active page button with aria-current="page"', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveAttribute('aria-current', 'page');
});
it('active page button has brand-navy background', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/bg-brand-navy/);
});
it('active page button has 44px touch target', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/);
await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/);
});
it('inactive page buttons link to their target page via makeHref', async () => {
const spy = vi.fn(makeHref);
render(Pagination, { page: 4, totalPages: 12, makeHref: spy });
const nav = page.getByRole('navigation');
// page button for page 1 (0-indexed: 0) should link to /documents?page=0
const firstPageBtn = nav.getByTestId('pagination-page-1');
await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0');
});
it('renders first and last page buttons always visible', async () => {
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument();
});
it('renders ellipsis span between first page and window when gap exists', async () => {
// page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipses = nav.getByTestId('pagination-ellipsis-left');
await expect.element(ellipses).toBeInTheDocument();
});
it('renders ellipsis span between window and last page when gap exists', async () => {
// page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(ellipsis).toBeInTheDocument();
});
it('does not render left ellipsis when window is adjacent to first page', async () => {
// page 1 (0-indexed: 0) — window starts at 1, adjacent to first page
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const leftEllipsis = nav.getByTestId('pagination-ellipsis-left');
await expect.element(leftEllipsis).not.toBeInTheDocument();
});
it('does not render right ellipsis when window is adjacent to last page', async () => {
// last page (0-indexed: 11) — window ends at 12, adjacent to last page
render(Pagination, { page: 11, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const rightEllipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(rightEllipsis).not.toBeInTheDocument();
});
it('page buttons container has hidden class on mobile (sm: prefix)', async () => {
// The page buttons container must be hidden below sm: breakpoint
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const pageButtons = nav.getByTestId('pagination-pages');
await expect.element(pageButtons).toHaveClass(/hidden/);
await expect.element(pageButtons).toHaveClass(/sm:flex/);
});
it('renders both pages without ellipsis when totalPages is 2', async () => {
render(Pagination, { page: 0, totalPages: 2, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument();
});
});
it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const nav = page.getByRole('navigation');
const srLabel = nav.getByTestId('pagination-current-page-sr');
await expect.element(srLabel).toBeInTheDocument();
await expect.element(srLabel).toHaveAttribute('aria-current', 'page');
});
it('renders prev as a link pointing at page - 1 when not on first page', async () => {

View File

@@ -91,7 +91,7 @@ let {
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-accent'}"
: 'bg-surface/10 text-primary'}"
>
<svg
class="h-3.5 w-3.5 shrink-0"

View File

@@ -0,0 +1,67 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PdfControls from './PdfControls.svelte';
afterEach(cleanup);
const defaultProps = {
currentPage: 1,
totalPages: 3,
isLoaded: true,
showAnnotations: false,
annotationCount: 0,
onPrev: vi.fn(),
onNext: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onToggleAnnotations: vi.fn()
};
describe('PdfControls — annotation toggle visibility', () => {
it('renders annotation toggle when annotationCount is greater than zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 3 });
await expect
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
.toBeInTheDocument();
});
it('does not render annotation toggle when annotationCount is zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 0 });
await expect
.element(page.getByRole('button', { name: /annotierungen/i }))
.not.toBeInTheDocument();
});
});
describe('PdfControls — annotation toggle label', () => {
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
await expect.element(btn).toBeInTheDocument();
});
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
await expect.element(btn).toBeInTheDocument();
});
});
describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
it('uses text-primary class on annotation toggle button when annotations are hidden', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('text-primary');
expect(annotationBtn!.className).not.toContain('text-accent');
});
});

View File

@@ -18,6 +18,7 @@ let {
activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick,
onTranscriptionDraw,
onDeleteAnnotationRequest,
documentFileHash,
annotationsDimmed = false,
flashAnnotationId = null
@@ -30,6 +31,7 @@ let {
activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
documentFileHash?: string | null;
annotationsDimmed?: boolean;
flashAnnotationId?: string | null;
@@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) {
flashAnnotationId={flashAnnotationId}
onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick}
onDeleteRequest={onDeleteAnnotationRequest}
/>
{/if}
</div>

View File

@@ -12,16 +12,25 @@ const PERSONS = [
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
},
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
{
id: '3',
firstName: 'Karl',
lastName: 'König',
displayName: 'Karl König',
personType: 'PERSON',
familyMember: false
}
];
function mockFetch(persons = PERSONS) {
@@ -62,14 +71,16 @@ describe('PersonMultiSelect rendering', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
]
});
@@ -86,14 +97,16 @@ describe('PersonMultiSelect rendering', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
]
});
@@ -112,7 +125,8 @@ describe('PersonMultiSelect rendering', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
]
});
@@ -166,7 +180,8 @@ describe('PersonMultiSelect selecting persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
]
});
@@ -187,7 +202,8 @@ describe('PersonMultiSelect selecting persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
]);
render(PersonMultiSelect, { selectedPersons: [] });
@@ -210,14 +226,16 @@ describe('PersonMultiSelect removing persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
]
});
@@ -236,14 +254,16 @@ describe('PersonMultiSelect removing persons', () => {
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
]
});

View File

@@ -19,6 +19,7 @@ interface Props {
autofocus?: boolean;
required?: boolean;
restrictToCorrespondentsOf?: string;
excludePersonId?: string;
badge?: 'additive' | 'replace';
onchange?: (value: string) => void;
onfocused?: () => void;
@@ -36,6 +37,7 @@ let {
autofocus = false,
required = false,
restrictToCorrespondentsOf,
excludePersonId,
badge,
onchange,
onfocused
@@ -61,21 +63,40 @@ $effect(() => {
const typeahead = createTypeahead<Person>({
fetchUrl: async (term) => {
const personId = restrictToCorrespondentsOf;
const excludeId = excludePersonId;
const filter = (results: Person[]) =>
excludeId ? results.filter((p) => p.id !== excludeId) : results;
if (personId) {
const url =
term.length >= 1
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
: `/api/persons/${personId}/correspondents`;
const res = await fetch(url);
return res.ok ? await res.json() : [];
return res.ok ? filter(await res.json()) : [];
}
if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
return res.ok ? await res.json() : [];
return res.ok ? filter(await res.json()) : [];
},
debounceMs: 300
});
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
let activeIndex = $state(-1);
// Stable id linking the input's aria-controls to the listbox element.
const listboxId = `${name}-listbox`;
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
if (value && searchTerm !== initialName) {
value = '';
@@ -88,6 +109,7 @@ function handleInput() {
function handleFocus() {
onfocused?.();
updateDropdownPosition();
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
(async () => {
@@ -109,13 +131,47 @@ function selectPerson(person: Person) {
value = person.id!;
searchTerm = person.displayName;
typeahead.close();
activeIndex = -1;
onchange?.(person.id!);
}
function closeDropdown() {
typeahead.close();
activeIndex = -1;
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) return;
const results = typeahead.results;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % results.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + results.length) % results.length;
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) {
selectPerson(results[activeIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
}
}
// Keep dropdown position current when user scrolls or resizes.
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
</script>
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
<label
for={name}
for="{name}-search"
class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: 'block text-sm font-medium text-ink-2'}
@@ -125,13 +181,22 @@ function selectPerson(person: Person) {
<input type="hidden" name={name} bind:value={value} />
<input
bind:this={inputEl}
type="text"
id="{name}-search"
role="combobox"
autocomplete="off"
autofocus={autofocus}
bind:value={searchTerm}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
: undefined}
oninput={handleInput}
onfocus={handleFocus}
onkeydown={handleKeydown}
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
class={large
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
@@ -140,29 +205,34 @@ function selectPerson(person: Person) {
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/>
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
<div
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
{#if isOpen}
<ul
id={listboxId}
role="listbox"
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#if typeahead.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
{:else}
{#each typeahead.results as person (person.id)}
<div
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
{#each typeahead.results as person, i (person.id)}
<li
id="{listboxId}-option-{person.id}"
role="option"
aria-selected={i === activeIndex}
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
tabindex="-1"
>
<div class="flex items-center">
<span class="block truncate font-medium">
{person.displayName}
</span>
</div>
</div>
</li>
{/each}
{/if}
</div>
</ul>
{/if}
</div>

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { page, userEvent } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -130,11 +130,11 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.element(page.getByRole('option', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -145,7 +145,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
await tick();
expect(hiddenInput('senderId')?.value).toBe('1');
@@ -158,7 +158,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
});
@@ -177,7 +177,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
});
@@ -194,7 +194,7 @@ describe('PersonTypeahead clearing a selection', () => {
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear();
@@ -285,3 +285,194 @@ describe('PersonTypeahead click outside', () => {
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});
// ─── ARIA roles ───────────────────────────────────────────────────────────────
describe('PersonTypeahead ARIA roles', () => {
it('dropdown uses role="listbox" container and role="option" items', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
// Container must be a listbox
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
// Items must be options, not buttons
const options = page.getByRole('option');
await expect.element(options.first()).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
});
it('input has aria-expanded="false" when dropdown is closed', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
});
it('input has aria-expanded="true" when dropdown is open', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
});
it('input has aria-controls pointing to the listbox id', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
const ariaControls = await input.element().getAttribute('aria-controls');
expect(ariaControls).toBeTruthy();
// The listbox with that id must exist
const listbox = document.getElementById(ariaControls!);
expect(listbox).not.toBeNull();
expect(listbox!.getAttribute('role')).toBe('listbox');
});
it('input has aria-haspopup="listbox"', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
});
});
// ─── Keyboard navigation ──────────────────────────────────────────────────────
describe('PersonTypeahead keyboard navigation', () => {
it('ArrowDown moves highlight to the first option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
// First option should be highlighted (aria-selected="true")
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
await userEvent.keyboard('{ArrowDown}');
await tick();
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowUp from first wraps to last option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}'); // highlight first
await tick();
await userEvent.keyboard('{ArrowUp}'); // wrap to last
await tick();
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowDown from last wraps to first option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
await tick();
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
await tick();
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
await tick();
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('Enter selects the highlighted option', async () => {
mockFetchWithPersons([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
await userEvent.keyboard('{Enter}');
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('Escape closes the dropdown without selecting', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await input.click();
await userEvent.keyboard('{Escape}');
await tick();
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
// Value unchanged — nothing was selected
await expect.element(input).toHaveValue('Mu');
});
it('active option id is set as aria-activedescendant on the input', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
// No active option before pressing ArrowDown
const beforeNav = await input.element().getAttribute('aria-activedescendant');
expect(beforeNav).toBeFalsy();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
const afterNav = await input.element().getAttribute('aria-activedescendant');
expect(afterNav).toBeTruthy();
});
});

View File

@@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props();
/>
</svg>
<span
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
>
{percentage}%
</span>

View File

@@ -25,12 +25,12 @@ describe('ProgressRing', () => {
expect(el.className).toContain('text-gray-400');
});
it('renders a mint-colored label when percentage is > 0', async () => {
it('renders a primary-colored label when percentage is > 0', async () => {
render(ProgressRing, { percentage: 75 });
const label = page.getByText('75%');
await expect.element(label).toBeInTheDocument();
const el = (await label.element()) as HTMLElement;
expect(el.className).toContain('text-accent');
expect(el.className).toContain('text-primary');
});
it('renders a fully filled arc for 100%', async () => {

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
interface Props {
chipLabel: string;
otherName: string;
yearRange?: string;
canWrite: boolean;
relId: string;
}
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
</script>
<li class="flex items-center gap-2 py-2">
<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"
>
{chipLabel}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName}
</span>
{#if yearRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
{/if}
{#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
<input type="hidden" name="relId" value={relId} />
<button
type="submit"
aria-label="{m.btn_delete()}{otherName}"
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-red-600"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
{/if}
</li>

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import RelationshipChip from './RelationshipChip.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
afterEach(cleanup);
const baseProps = {
chipLabel: 'Elternteil',
otherName: 'Anna Schmidt',
yearRange: '',
canWrite: false,
relId: 'rel-1'
};
describe('RelationshipChip', () => {
it('renders the chip label', async () => {
render(RelationshipChip, baseProps);
await expect.element(page.getByText('Elternteil')).toBeInTheDocument();
});
it('renders the other person name', async () => {
render(RelationshipChip, baseProps);
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
it('shows year range when provided', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '19201980' });
await expect.element(page.getByText('19201980')).toBeInTheDocument();
});
it('does not show year range span when empty', async () => {
render(RelationshipChip, { ...baseProps, yearRange: '' });
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
});
it('shows delete button when canWrite is true', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
await expect.element(page.getByRole('button')).toBeInTheDocument();
});
it('hides delete button when canWrite is false', async () => {
render(RelationshipChip, { ...baseProps, canWrite: false });
expect(document.querySelector('button')).toBeNull();
});
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => {
render(RelationshipChip, { ...baseProps, canWrite: true });
const btn = document.querySelector('button')!;
expect(btn.className).toContain('h-11');
expect(btn.className).toContain('w-11');
});
});

View File

@@ -0,0 +1,10 @@
<script lang="ts">
type Props = { label: string };
let { label }: Props = $props();
</script>
<span
class="inline-flex shrink-0 items-center rounded-full border border-accent bg-accent/25 px-2 py-px font-sans text-[10px] font-bold tracking-[0.07em] text-ink uppercase"
>
{label}
</span>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import RelationshipChip from '$lib/components/RelationshipChip.svelte';
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
personId: string;
familyMember: boolean;
relationships: RelationshipDTO[];
inferredRelationships: InferredRelationshipWithPersonDTO[];
canWrite: boolean;
relationshipError?: string | null;
}
let {
personId,
familyMember,
relationships,
inferredRelationships,
canWrite,
relationshipError = null
}: Props = $props();
type RelationType = NonNullable<RelationshipDTO['relationType']>;
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
const topDerived = $derived(inferredRelationships.slice(0, 5));
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
if (order !== 0) return order;
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
}
function relationTypeOrder(t: RelationType | undefined): number {
const order: Record<string, number> = {
PARENT_OF: 1,
SPOUSE_OF: 2,
SIBLING_OF: 3,
FRIEND: 4,
COLLEAGUE: 5,
EMPLOYER: 6,
DOCTOR: 7,
NEIGHBOR: 8,
OTHER: 9
};
return order[t ?? 'OTHER'] ?? 99;
}
function yearRange(rel: RelationshipDTO): string {
const from = rel.fromYear;
const to = rel.toYear;
if (from && to) return `${from}${to}`;
if (from) return m.relation_year_from({ year: from });
if (to) return m.relation_year_to({ year: to });
return '';
}
</script>
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
<!-- Header row: heading + family-member toggle -->
<div class="mb-5 flex items-start justify-between gap-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_relationships_heading()}
</h2>
{#if canWrite}
<form method="POST" action="?/toggleFamilyMember" use:enhance>
<input type="hidden" name="familyMember" value={familyMember ? 'false' : 'true'} />
<button
type="submit"
role="switch"
aria-checked={familyMember}
aria-label={familyMember
? m.relation_toggle_remove_from_tree()
: m.relation_toggle_add_to_tree()}
class="inline-flex items-center gap-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
<span
class="relative inline-block h-4 w-7 rounded-full transition-colors {familyMember
? 'bg-primary'
: 'bg-line'}"
>
<span
class="absolute top-0.5 left-0.5 inline-block h-3 w-3 rounded-full bg-white transition-transform {familyMember
? 'translate-x-3'
: ''}"
></span>
</span>
{m.relation_label_family_member()}
</button>
</form>
{/if}
</div>
{#if relationshipError}
<p class="mb-3 text-sm text-red-700" role="alert">{relationshipError}</p>
{/if}
<!-- In-tree banner -->
{#if familyMember}
<div
class="mb-4 flex items-center justify-between rounded-sm border border-accent/30 bg-accent/10 px-3 py-2"
>
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full bg-accent"></span>
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_in_tree()}</span>
</div>
<a
href="/stammbaum?focus={personId}"
class="font-sans text-xs font-medium text-primary hover:underline"
>
{m.relation_label_view_in_tree()}
</a>
</div>
{/if}
<!-- Direkte Beziehungen -->
<h3 class="mb-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.relation_label_direct()}
</h3>
{#if sortedDirect.length === 0}
<p class="mb-2 text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="mb-2 divide-y divide-line">
{#each sortedDirect as rel (rel.id)}
<RelationshipChip
chipLabel={chipLabel(rel, personId)}
otherName={otherName(rel, personId)}
yearRange={yearRange(rel)}
canWrite={canWrite}
relId={rel.id}
/>
{/each}
</ul>
{/if}
{#if canWrite}
<AddRelationshipForm personId={personId} />
{/if}
<!-- Abgeleitete Beziehungen -->
{#if topDerived.length > 0}
<details class="mt-6">
<summary
class="cursor-pointer text-xs font-bold tracking-widest text-ink-3 uppercase select-none"
>
{m.relation_label_derived()}
</summary>
<ul class="mt-2 space-y-2">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<a
href="/persons/{derived.person.id}"
class="min-w-0 flex-1 truncate font-serif text-sm text-ink-2 hover:underline"
>
{derived.person.displayName}
</a>
</li>
{/each}
</ul>
</details>
{/if}
</div>

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import StammbaumCard from './StammbaumCard.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/components/RelationshipChip.svelte', () => ({ default: () => null }));
vi.mock('$lib/components/AddRelationshipForm.svelte', () => ({ default: () => null }));
afterEach(cleanup);
const baseProps = {
personId: 'person-1',
familyMember: false,
relationships: [],
inferredRelationships: [],
canWrite: false
};
describe('StammbaumCard', () => {
it('renders the section heading', async () => {
render(StammbaumCard, baseProps);
await expect.element(page.getByText('Stammbaum & Beziehungen')).toBeInTheDocument();
});
it('shows empty-relationships message when relationships list is empty', async () => {
render(StammbaumCard, baseProps);
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
});
it('renders the family-member toggle when canWrite is true', async () => {
render(StammbaumCard, { ...baseProps, canWrite: true });
await expect.element(page.getByText('Als Familienmitglied')).toBeInTheDocument();
});
it('displays relationshipError text when provided', async () => {
render(StammbaumCard, { ...baseProps, relationshipError: 'Test Fehler' });
await expect.element(page.getByText('Test Fehler')).toBeInTheDocument();
});
it('toggle aria-label says "Zum Stammbaum hinzufügen" when not yet a family member', async () => {
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: false });
await expect
.element(page.getByRole('switch', { name: 'Zum Stammbaum hinzufügen' }))
.toBeInTheDocument();
});
it('toggle aria-label says "Aus Stammbaum entfernen" when already a family member', async () => {
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: true });
await expect
.element(page.getByRole('switch', { name: 'Aus Stammbaum entfernen' }))
.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
import type { RelFormData } from '$lib/components/AddRelationshipForm.svelte';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
interface Props {
node: PersonNodeDTO;
onClose: () => void;
canWrite?: boolean;
}
let { node, onClose, canWrite = false }: Props = $props();
let directRels = $state<RelationshipDTO[]>([]);
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
const id = node.id;
loadFor(id);
});
async function loadFor(id: string) {
loading = true;
error = null;
try {
const [directRes, derivedRes] = await Promise.all([
fetch(`/api/persons/${id}/relationships`),
fetch(`/api/persons/${id}/inferred-relationships`)
]);
if (!directRes.ok || !derivedRes.ok) {
error = m.error_internal_error();
return;
}
directRels = await directRes.json();
derivedRels = await derivedRes.json();
} catch {
error = m.error_internal_error();
} finally {
loading = false;
}
}
async function handleAddRelationship(data: RelFormData) {
const body: Record<string, string | number> = {
relatedPersonId: data.relatedPersonId,
relationType: data.relationType
};
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
if (data.toYear !== undefined) body.toYear = data.toYear;
const res = await fetch(`/api/persons/${node.id}/relationships`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('Failed to add relationship');
await loadFor(node.id);
await invalidateAll();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
onMount(() => {
const handler = (e: KeyboardEvent) => handleEscape(e);
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const directOtherIds = $derived(
new Set(directRels.map((r) => (r.personId === node.id ? r.relatedPersonId : r.personId)))
);
const topDerived = $derived(
derivedRels.filter((d) => !directOtherIds.has(d.person.id)).slice(0, 5)
);
</script>
<div class="flex h-full flex-col p-5">
<div class="mb-4 flex items-start justify-between gap-2">
<div class="min-w-0">
<h2 class="font-serif text-lg text-ink">{node.displayName}</h2>
{#if node.birthYear || node.deathYear}
<p class="font-sans text-xs text-ink-3">
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</p>
{/if}
</div>
<button
type="button"
onclick={onClose}
aria-label={m.comp_dismiss()}
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<svg
class="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
{#if error}
<p class="mb-3 text-sm text-red-700" role="alert">{error}</p>
{:else if loading}
<p class="font-sans text-xs text-ink-3 italic"></p>
{:else}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_direct_rels()}
</h3>
{#if directRels.length === 0}
<p class="text-xs text-ink-2 italic">{m.person_relationships_empty()}</p>
{:else}
<ul class="space-y-1.5">
{#each directRels as rel (rel.id)}
<li class="flex items-center gap-2">
<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"
>
{chipLabel(rel, node.id)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink">
{otherName(rel, node.id)}
</span>
</li>
{/each}
</ul>
{/if}
{#if canWrite}
{#key node.id}
<AddRelationshipForm personId={node.id} onSubmit={handleAddRelationship} />
{/key}
{/if}
</section>
{#if topDerived.length > 0}
<section class="mb-5">
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.stammbaum_panel_derived_rels()}
</h3>
<ul class="space-y-1.5">
{#each topDerived as derived (derived.person.id)}
<li class="flex items-center gap-2">
<span
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{inferredRelationshipLabel(derived.label)}
</span>
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink-2">
{derived.person.displayName}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<div class="mt-auto">
<a
href="/persons/{node.id}"
class="block w-full rounded-sm border border-line bg-surface px-3 py-2 text-center font-sans text-xs font-medium text-primary transition hover:bg-muted"
>
{m.stammbaum_panel_to_person()}
</a>
</div>
</div>

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
const makeNode = () => ({
id: 'person-1',
displayName: 'Alice Müller',
birthYear: 1900,
deathYear: null,
familyMember: true
});
function stubFetch(directRels: unknown[] = [], inferredRels: unknown[] = []) {
let callCount = 0;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => {
callCount++;
const body = callCount % 2 === 1 ? directRels : inferredRels;
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
})
);
}
beforeEach(() => stubFetch());
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('StammbaumSidePanel', () => {
it('calls onClose when dismiss button is clicked', async () => {
const onClose = vi.fn();
render(StammbaumSidePanel, { node: makeNode(), onClose, canWrite: false });
await expect.element(page.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
const btn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.getAttribute('aria-label') === 'Schließen'
);
btn!.click();
expect(onClose).toHaveBeenCalledOnce();
});
it('renders the person displayName as heading', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('Alice Müller')).toBeInTheDocument();
});
it('shows empty-relationships message when no direct relationships are loaded', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
});
it('year inputs inside the add form have label elements (canWrite=true)', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
/Beziehung hinzufügen/i.test(b.textContent ?? '')
);
addBtn!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const yearInputs = [...document.querySelectorAll('input')].filter(
(i) => i.inputMode === 'numeric'
);
expect(yearInputs.length).toBeGreaterThan(0);
for (const input of yearInputs) {
expect(input.closest('label')).not.toBeNull();
}
});
it('shows loading indicator while fetching', async () => {
let resolveFirst: (v: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
})
)
);
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('…')).toBeInTheDocument();
resolveFirst!({ ok: true, json: () => Promise.resolve([]) });
});
});

View File

@@ -0,0 +1,548 @@
<script lang="ts">
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
to a single $derived.by computation; never mutated after layout. */
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
zoom: number;
onSelect: (id: string) => void;
}
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
const NODE_W = 160;
const NODE_H = 56;
const COL_GAP = 40;
const ROW_GAP = 80;
const VIEWBOX_PAD = 80;
// Minimum viewBox dimensions — keeps a single node from being scaled up
// to fill the entire canvas. Roughly matches a typical desktop content area.
const MIN_VIEWBOX_W = 1200;
const MIN_VIEWBOX_H = 800;
type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
viewX: number;
viewY: number;
viewW: number;
viewH: number;
};
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
const viewBox = $derived.by(() => {
const w = layout.viewW / zoom;
const h = layout.viewH / zoom;
const cx = layout.viewX + layout.viewW / 2;
const cy = layout.viewY + layout.viewH / 2;
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
const childToParents = new Map<string, string[]>();
const spousePairs = new Map<string, string>();
for (const e of allEdges) {
switch (e.relationType) {
case 'PARENT_OF':
mapPush(parentToChildren, e.personId, e.relatedPersonId);
mapPush(childToParents, e.relatedPersonId, e.personId);
break;
case 'SPOUSE_OF':
spousePairs.set(e.personId, e.relatedPersonId);
spousePairs.set(e.relatedPersonId, e.personId);
break;
}
}
// Iterative longest-path generation assignment.
//
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
// Then spouses are pulled to share the deeper generation. Pulling a spouse
// down can shift their own descendants, so we iterate until stable rather
// than running BFS once like the previous implementation (which left
// e.g. a child of a "later-pulled" spouse stranded one row too high).
const generation = new Map<string, number>();
for (const n of allNodes) generation.set(n.id, 0);
const maxIters = allNodes.length + 4;
for (let it = 0; it < maxIters; it++) {
let changed = false;
for (const n of allNodes) {
const parents = childToParents.get(n.id) ?? [];
if (parents.length === 0) continue;
let maxParentGen = -1;
for (const pid of parents) {
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
}
const newGen = maxParentGen + 1;
if ((generation.get(n.id) ?? 0) < newGen) {
generation.set(n.id, newGen);
changed = true;
}
}
for (const [a, b] of spousePairs) {
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
if ((generation.get(a) ?? 0) < m) {
generation.set(a, m);
changed = true;
}
if ((generation.get(b) ?? 0) < m) {
generation.set(b, m);
changed = true;
}
}
if (!changed) break;
}
// Group by generation, then sort within generation by display name.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = generation.get(n.id) ?? 0;
if (!generations.has(g)) generations.set(g, []);
generations.get(g)!.push(n.id);
}
const byId = new Map(allNodes.map((n) => [n.id, n]));
for (const ids of generations.values()) {
ids.sort((a, b) => {
const an = byId.get(a)?.displayName ?? '';
const bn = byId.get(b)?.displayName ?? '';
return an.localeCompare(bn);
});
}
// Per-generation layout:
//
// 1. Build sibling-groups (children of the same parent set) — these become
// the layout "blocks" that are centred under their parents' midpoint.
// 2. Attach loose spouses (people with no parents in the graph but a
// spouse who *is* in a sibling group) on the outside of their partner,
// so the spouse line stays short and adjacent.
// 3. Merge dual-loose spouse pairs into a single 2-person block.
// 4. Centre each block such that its *parented* members average sits
// exactly under the parent midpoint (keeping all connectors at 90°),
// then pack blocks left-to-right.
type Block = {
members: { id: string; parented: boolean }[];
center: number;
};
const positions = new Map<string, { x: number; y: number }>();
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (let gi = 0; gi < sortedGens.length; gi++) {
const g = sortedGens[gi];
const ids = generations.get(g)!;
const y = g * (NODE_H + ROW_GAP);
const blocksByKey = new Map<string, Block>();
const memberLookup = new Map<string, { key: string; parented: boolean }>();
// Step 1: place every node with parents-in-graph into a sibling block.
for (const id of ids) {
const parents = childToParents.get(id) ?? [];
if (parents.length === 0) continue;
const blockKey = [...parents].sort().join('|');
let block = blocksByKey.get(blockKey);
if (!block) {
const parentCenters: number[] = [];
for (const pid of parents) {
const p = positions.get(pid);
if (p) parentCenters.push(p.x + NODE_W / 2);
}
const center =
parentCenters.length > 0
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
: 0;
block = { members: [], center };
blocksByKey.set(blockKey, block);
}
block.members.push({ id, parented: true });
memberLookup.set(id, { key: blockKey, parented: true });
}
// Sort members within each sibling block alphabetically.
for (const block of blocksByKey.values()) {
block.members.sort((a, b) =>
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
);
}
// Step 2 + 3: handle loose nodes.
for (const id of ids) {
if (memberLookup.has(id)) continue;
const spouse = spousePairs.get(id);
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
if (spouseLookup && spouseLookup.parented) {
// Spouse is parented — attach this loose node next to them on
// the outer edge of their sibling block so the marriage line
// is short and the sibling order is preserved.
const block = blocksByKey.get(spouseLookup.key)!;
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
const insertOnRight = spouseIdx >= block.members.length / 2;
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
block.members.splice(insertAt, 0, { id, parented: false });
memberLookup.set(id, { key: spouseLookup.key, parented: false });
} else {
// No usable parented spouse: put in its own loose block. We
// merge dual-loose spouse pairs in the next pass.
const blockKey = `__loose__${id}`;
blocksByKey.set(blockKey, {
members: [{ id, parented: false }],
center: 0
});
memberLookup.set(id, { key: blockKey, parented: false });
}
}
// Merge dual-loose spouse blocks into a single 2-person block.
const removed = new Set<string>();
for (const [key, block] of blocksByKey) {
if (!key.startsWith('__loose__')) continue;
if (removed.has(key)) continue;
const member = block.members[0];
const spouse = spousePairs.get(member.id);
if (!spouse) continue;
const spouseLookup = memberLookup.get(spouse);
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
if (spouseLookup.key === key) continue;
if (!spouseLookup.key.startsWith('__loose__')) continue;
const otherBlock = blocksByKey.get(spouseLookup.key)!;
block.members.push(...otherBlock.members);
removed.add(spouseLookup.key);
}
for (const key of removed) blocksByKey.delete(key);
// Step 4: centre each block on its anchor (parented members) and pack.
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
let cursorRight = -Infinity;
for (const block of ordered) {
const n = block.members.length;
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
const anchorIndices: number[] = [];
for (let i = 0; i < n; i++) {
if (block.members[i].parented) anchorIndices.push(i);
}
const avgAnchorIdx =
anchorIndices.length > 0
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
: (n - 1) / 2;
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
for (let i = 0; i < n; i++) {
positions.set(block.members[i].id, {
x: groupLeft + i * (NODE_W + COL_GAP),
y
});
}
cursorRight = groupLeft + groupWidth;
}
}
// Bounding box around the actual content, then expanded to MIN dimensions
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
// is centered on the content.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of positions.values()) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x + NODE_W);
maxY = Math.max(maxY, p.y + NODE_H);
}
if (positions.size === 0) {
minX = 0;
minY = 0;
maxX = 0;
maxY = 0;
}
const contentW = maxX - minX;
const contentH = maxY - minY;
const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
const viewX = minX + contentW / 2 - viewW / 2;
const viewY = minY + contentH / 2 - viewH / 2;
return { positions, generations, viewX, viewY, viewW, viewH };
}
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
const arr = map.get(key);
if (arr) arr.push(value);
else map.set(key, [value]);
}
function nodeCenter(id: string): { x: number; y: number } | null {
const p = layout.positions.get(id);
if (!p) return null;
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
}
let focusedId = $state<string | null>(null);
function handleNodeKey(event: KeyboardEvent, id: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(id);
}
}
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
function pairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
type ParentLinks = {
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
// + per-child vertical pattern in the SVG.
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
// One entry per remaining parent → child edge (single parents, or the
// "second" parent edge when only one parent is in the spouse pair).
single: { key: string; parentId: string; childId: string }[];
};
const parentLinks = $derived.by<ParentLinks>(() => {
const spousePairs = new Set<string>();
for (const e of spouseEdges) {
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
}
const childToParents = new Map<string, string[]>();
for (const e of parentEdges) {
const list = childToParents.get(e.relatedPersonId) ?? [];
list.push(e.personId);
childToParents.set(e.relatedPersonId, list);
}
const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
const single: ParentLinks['single'] = [];
for (const [childId, parents] of childToParents) {
const consumed = new Set<string>();
for (let i = 0; i < parents.length; i++) {
if (consumed.has(parents[i])) continue;
for (let j = i + 1; j < parents.length; j++) {
if (consumed.has(parents[j])) continue;
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
const groupKey = pairKey(parents[i], parents[j]);
const existing = sharedMap.get(groupKey);
if (existing) {
existing.childIds.push(childId);
} else {
sharedMap.set(groupKey, {
parentA: parents[i],
parentB: parents[j],
childIds: [childId]
});
}
consumed.add(parents[i]);
consumed.add(parents[j]);
break;
}
}
}
for (const parentId of parents) {
if (consumed.has(parentId)) continue;
single.push({ key: `${parentId}->${childId}`, parentId, childId });
}
}
const shared: ParentLinks['shared'] = [];
for (const [key, group] of sharedMap) shared.push({ key, ...group });
return { shared, single };
});
</script>
<svg
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
bar, then short verticals from the bar to each child top. -->
{#each parentLinks.shared as group (group.key)}
{@const aCenter = nodeCenter(group.parentA)}
{@const bCenter = nodeCenter(group.parentB)}
{@const childCenters = group.childIds
.map((id) => nodeCenter(id))
.filter((c): c is { x: number; y: number } => c !== null)}
{#if aCenter && bCenter && childCenters.length > 0}
{@const midX = (aCenter.x + bCenter.x) / 2}
{@const parentBottomY = aCenter.y + NODE_H / 2}
{@const childTopY = childCenters[0].y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
{@const xs = childCenters.map((c) => c.x)}
{@const minX = Math.min(midX, ...xs)}
{@const maxX = Math.max(midX, ...xs)}
<line
x1={midX}
y1={parentBottomY}
x2={midX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if minX !== maxX}
<line
x1={minX}
y1={barY}
x2={maxX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{#each childCenters as cc, i (group.childIds[i])}
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/each}
{/if}
{/each}
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
{#each parentLinks.single as link (link.key)}
{@const parentCenter = nodeCenter(link.parentId)}
{@const childCenter = nodeCenter(link.childId)}
{#if parentCenter && childCenter}
{@const parentBottomY = parentCenter.y + NODE_H / 2}
{@const childTopY = childCenter.y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
<line
x1={parentCenter.x}
y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
<line
x1={parentCenter.x}
y1={barY}
x2={childCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{/each}
<!-- Spouse connectors -->
{#each spouseEdges as e (e.id)}
{@const aCenter = nodeCenter(e.personId)}
{@const bCenter = nodeCenter(e.relatedPersonId)}
{#if aCenter && bCenter}
<line
x1={aCenter.x}
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="4.5"
fill="var(--c-primary)"
/>
{/if}
{/each}
<!-- Nodes -->
{#each nodes as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
{@const isSelected = selectedId === node.id}
{@const isFocused = focusedId === node.id}
<g
role="button"
tabindex="0"
aria-label="{node.displayName}{node.birthYear || node.deathYear
? `, ${node.birthYear ?? '?'}${node.deathYear ?? ''}`
: ''}"
aria-expanded={isSelected}
transform="translate({pos.x}, {pos.y})"
onclick={() => onSelect(node.id)}
onkeydown={(e) => handleNodeKey(e, node.id)}
onfocus={() => (focusedId = node.id)}
onblur={() => (focusedId = null)}
class="cursor-pointer focus:outline-none"
>
{#if isFocused}
<rect
x="-3"
y="-3"
width={NODE_W + 6}
height={NODE_H + 6}
rx="6"
fill="none"
stroke="var(--c-focus-ring)"
stroke-width="2"
/>
{/if}
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if isSelected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={isSelected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
{/if}
{/each}
</svg>

View File

@@ -0,0 +1,349 @@
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
function parseViewBox(svg: SVGElement): [number, number, number, number] {
const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number);
return [parts[0], parts[1], parts[2], parts[3]];
}
function rectsCentroid(svg: SVGElement): { x: number; y: number } {
const rects = Array.from(svg.querySelectorAll('rect'));
let sx = 0;
let sy = 0;
let n = 0;
for (const r of rects) {
const x = parseFloat(r.getAttribute('x') ?? '0');
const y = parseFloat(r.getAttribute('y') ?? '0');
const w = parseFloat(r.getAttribute('width') ?? '0');
const h = parseFloat(r.getAttribute('height') ?? '0');
// Skip the narrow accent stripe.
if (w < 10) continue;
// Each node rect lives inside <g transform="translate(...)">.
const g = r.closest('g[transform]');
const transform = g?.getAttribute('transform') ?? '';
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
const tx = match ? parseFloat(match[1]) : 0;
const ty = match ? parseFloat(match[2]) : 0;
sx += tx + x + w / 2;
sy += ty + y + h / 2;
n++;
}
return { x: sx / n, y: sy / n };
}
describe('StammbaumTree viewBox', () => {
it('uses the minimum size and centers a single node', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const svg = document.querySelector('svg')!;
const [x, y, w, h] = parseViewBox(svg);
// Single 160x56 node fits inside the 1200x800 minimum viewBox.
expect(w).toBe(1200);
expect(h).toBe(800);
// Whatever absolute coordinates the layout uses, the viewBox must
// centre on the rendered content.
const c = rectsCentroid(svg);
expect(x + w / 2).toBeCloseTo(c.x, 1);
expect(y + h / 2).toBeCloseTo(c.y, 1);
});
it('renders only orthogonal segments when two parents share two children', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD_1 = '00000000-0000-0000-0000-00000000000c';
const CHILD_2 = '00000000-0000-0000-0000-00000000000d';
render(StammbaumTree, {
nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
{ id: CHILD_1, displayName: 'Clara', familyMember: true },
{ id: CHILD_2, displayName: 'Hans', familyMember: true }
],
edges: [
{
id: 'sp',
personId: PARENT_A,
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1a',
personId: PARENT_A,
relatedPersonId: CHILD_1,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 'p1b',
personId: PARENT_B,
relatedPersonId: CHILD_1,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 'p2a',
personId: PARENT_A,
relatedPersonId: CHILD_2,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2b',
personId: PARENT_B,
relatedPersonId: CHILD_2,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = Array.from(document.querySelectorAll('svg line'));
// Every parent-child segment must be either vertical (x1==x2) or
// horizontal (y1==y2) — no slanted segments allowed.
const slanted = lines.filter(
(l) =>
l.getAttribute('x1') !== l.getAttribute('x2') &&
l.getAttribute('y1') !== l.getAttribute('y2')
);
expect(slanted).toHaveLength(0);
// Sibling bar must exist and span the children: at least one
// horizontal line whose x1 != x2 and y1 == y2.
const horizontalBars = lines.filter(
(l) =>
l.getAttribute('y1') === l.getAttribute('y2') &&
l.getAttribute('x1') !== l.getAttribute('x2')
);
expect(horizontalBars.length).toBeGreaterThanOrEqual(1);
});
it('positions a single child at the midpoint of its two parents (vertical drop)', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD = '00000000-0000-0000-0000-00000000000c';
render(StammbaumTree, {
nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
{ id: CHILD, displayName: 'Hans', familyMember: true }
],
edges: [
{
id: 'sp',
personId: PARENT_A,
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1',
personId: PARENT_A,
relatedPersonId: CHILD,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2',
personId: PARENT_B,
relatedPersonId: CHILD,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = Array.from(document.querySelectorAll('svg line'));
// No slanted segments. With one child, no horizontal sibling bar
// is needed because midX == child.center.x.
const slanted = lines.filter(
(l) =>
l.getAttribute('x1') !== l.getAttribute('x2') &&
l.getAttribute('y1') !== l.getAttribute('y2')
);
expect(slanted).toHaveLength(0);
});
it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => {
// Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1).
// Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have
// child Lili. Hilde must sit next to Hans, and Lili must be on a
// row below Hans/Hilde — not on the same row.
const WALTER = '00000000-0000-0000-0000-000000000001';
const EUGENIE = '00000000-0000-0000-0000-000000000002';
const HANS = '00000000-0000-0000-0000-000000000003';
const CLARA = '00000000-0000-0000-0000-000000000004';
const HILDE = '00000000-0000-0000-0000-000000000005';
const LILI = '00000000-0000-0000-0000-000000000006';
render(StammbaumTree, {
nodes: [
{ id: WALTER, displayName: 'Walter', familyMember: true },
{ id: EUGENIE, displayName: 'Eugenie', familyMember: true },
{ id: HANS, displayName: 'Hans', familyMember: true },
{ id: CLARA, displayName: 'Clara', familyMember: true },
{ id: HILDE, displayName: 'Hilde', familyMember: true },
{ id: LILI, displayName: 'Lili', familyMember: true }
],
edges: [
{
id: 's1',
personId: WALTER,
relatedPersonId: EUGENIE,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1',
personId: WALTER,
relatedPersonId: HANS,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2',
personId: EUGENIE,
relatedPersonId: HANS,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p3',
personId: WALTER,
relatedPersonId: CLARA,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 'p4',
personId: EUGENIE,
relatedPersonId: CLARA,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Clara',
relationType: 'PARENT_OF'
},
{
id: 's2',
personId: HANS,
relatedPersonId: HILDE,
personDisplayName: 'Hans',
relatedPersonDisplayName: 'Hilde',
relationType: 'SPOUSE_OF'
},
{
id: 'p5',
personId: HANS,
relatedPersonId: LILI,
personDisplayName: 'Hans',
relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF'
},
{
id: 'p6',
personId: HILDE,
relatedPersonId: LILI,
personDisplayName: 'Hilde',
relatedPersonDisplayName: 'Lili',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const ys = new Map<string, number>();
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
const aria = g.getAttribute('aria-label') ?? '';
const transform = g.getAttribute('transform') ?? '';
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
if (!match) continue;
ys.set(aria.split(',')[0], parseFloat(match[2]));
}
// Lili must be on a deeper row than Hans / Hilde.
expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!);
expect(ys.get('Hans')).toEqual(ys.get('Hilde'));
// Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP).
const xs = new Map<string, number>();
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
const aria = g.getAttribute('aria-label') ?? '';
const transform = g.getAttribute('transform') ?? '';
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
if (!match) continue;
xs.set(aria.split(',')[0], parseFloat(match[1]));
}
expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40);
// All parent-child segments must be orthogonal.
const lines = Array.from(document.querySelectorAll('svg line'));
const slanted = lines.filter(
(l) =>
l.getAttribute('x1') !== l.getAttribute('x2') &&
l.getAttribute('y1') !== l.getAttribute('y2')
);
expect(slanted).toHaveLength(0);
});
it('centers two spouse nodes within the minimum viewBox', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [
{
id: 'e1',
personId: ID_A,
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const svg = document.querySelector('svg')!;
const [x, y, w, h] = parseViewBox(svg);
expect(w).toBe(1200);
expect(h).toBe(800);
const c = rectsCentroid(svg);
expect(x + w / 2).toBeCloseTo(c.x, 1);
expect(y + h / 2).toBeCloseTo(c.y, 1);
});
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type Theme = 'light' | 'dark';
@@ -19,6 +20,10 @@ onMount(() => {
theme = resolveInitialTheme();
});
const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
@@ -29,8 +34,8 @@ function toggle() {
<button
type="button"
onclick={toggle}
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
title={theme === 'dark' ? 'light mode' : 'dark mode'}
aria-label={themeLabel}
title={themeLabel}
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}

View File

@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ThemeToggle from './ThemeToggle.svelte';
afterEach(() => {
cleanup();
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
});
it('title equals aria-label in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});

View File

@@ -19,6 +19,7 @@ type Props = {
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>;
onMarkAllReviewed?: () => Promise<void>;
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
canWrite?: boolean;
trainingLabels?: string[];
@@ -37,6 +38,7 @@ let {
onSaveBlock,
onDeleteBlock,
onReviewToggle,
onMarkAllReviewed,
onTriggerOcr,
canWrite = false,
trainingLabels = [],
@@ -46,12 +48,14 @@ let {
let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0);
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
const totalCount = $derived(blocks.length);
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => {
@@ -60,6 +64,16 @@ $effect(() => {
if (block) activeBlockId = block.id;
});
async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return;
markingAllReviewed = true;
try {
await onMarkAllReviewed();
} finally {
markingAllReviewed = false;
}
}
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
const dragDrop = createBlockDragDrop({
@@ -147,9 +161,56 @@ async function handleLabelToggle(label: string) {
{#if hasBlocks}
<!-- Sticky review progress header -->
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
<p class="font-sans text-xs text-ink-2">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p>
<div class="flex items-center justify-between">
<p class="font-sans text-xs text-ink-2">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p>
{#if onMarkAllReviewed}
<button
onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
>
{#if markingAllReviewed}
<svg
class="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
Alle als fertig markieren
</button>
{/if}
</div>
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
<div
class="h-full rounded-full bg-brand-mint transition-all duration-300"

View File

@@ -49,6 +49,11 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
};
}
const unreviewedBlock1 = { ...block1, reviewed: false };
const unreviewedBlock2 = { ...block2, reviewed: false };
const reviewedBlock1 = { ...block1, reviewed: true };
const reviewedBlock2 = { ...block2, reviewed: true };
describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => {
renderView();
@@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => {
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
});
});
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
describe('TranscriptionEditView — mark all reviewed', () => {
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument();
});
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument();
});
it('disables button when all blocks are already reviewed', async () => {
renderView({
blocks: [reviewedBlock1, reviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
});
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
});
it('disables button while operation is in-flight', async () => {
let resolveMarkAll!: () => void;
const onMarkAllReviewed = vi
.fn()
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
await btn.click();
await expect.element(btn).toBeDisabled();
resolveMarkAll();
});
});

View File

@@ -38,6 +38,9 @@ export type ErrorCode =
| 'TAG_NOT_FOUND'
| 'TAG_MERGE_SELF'
| 'TAG_MERGE_INVALID_TARGET'
| 'RELATIONSHIP_NOT_FOUND'
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -136,6 +139,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_tag_merge_self();
case 'TAG_MERGE_INVALID_TARGET':
return m.error_tag_merge_invalid_target();
case 'RELATIONSHIP_NOT_FOUND':
return m.error_relationship_not_found();
case 'CIRCULAR_RELATIONSHIP':
return m.error_circular_relationship();
case 'DUPLICATE_RELATIONSHIP':
return m.error_duplicate_relationship();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':

View File

@@ -212,6 +212,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/relationships": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRelationships"];
put?: never;
post: operations["addRelationship"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/merge": {
parameters: {
query?: never;
@@ -612,6 +628,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/family-member": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["patchFamilyMember"];
trace?: never;
};
"/api/notifications/{id}/read": {
parameters: {
query?: never;
@@ -852,6 +884,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/inferred-relationships": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getInferredRelationships"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/documents": {
parameters: {
query?: never;
@@ -884,6 +932,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{aId}/relationship-to/{bId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRelationshipBetween"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/ocr/training-info": {
parameters: {
query?: never;
@@ -1044,6 +1108,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/network": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getNetwork"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{id}/versions": {
parameters: {
query?: never;
@@ -1332,6 +1412,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/relationships/{relId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteRelationship"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/aliases/{aliasId}": {
parameters: {
query?: never;
@@ -1430,6 +1526,8 @@ export interface components {
color?: string;
};
PersonUpdateDTO: {
/** @enum {string} */
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
title?: string;
firstName?: string;
lastName?: string;
@@ -1454,6 +1552,7 @@ export interface components {
birthYear?: number;
/** Format: int32 */
deathYear?: number;
familyMember: boolean;
readonly displayName: string;
};
DocumentUpdateDTO: {
@@ -1462,6 +1561,8 @@ export interface components {
documentDate?: string;
location?: string;
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
transcription?: string;
summary?: string;
/** Format: uuid */
@@ -1561,6 +1662,41 @@ export interface components {
/** Format: uuid */
targetId: string;
};
CreateRelationshipRequest: {
/** Format: uuid */
relatedPersonId: string;
relationType: string;
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
RelationshipDTO: {
/** Format: uuid */
id: string;
/** Format: uuid */
personId: string;
/** Format: uuid */
relatedPersonId: string;
personDisplayName: string;
/** Format: int32 */
personBirthYear?: number;
/** Format: int32 */
personDeathYear?: number;
relatedPersonDisplayName: string;
/** Format: int32 */
relatedPersonBirthYear?: number;
/** Format: int32 */
relatedPersonDeathYear?: number;
/** @enum {string} */
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
/** Format: int32 */
fromYear?: number;
/** Format: int32 */
toYear?: number;
notes?: string;
};
PersonNameAliasDTO: {
lastName: string;
firstName?: string;
@@ -1808,6 +1944,9 @@ export interface components {
/** Format: int32 */
count: number;
};
FamilyMemberPatchDTO: {
familyMember?: boolean;
};
NotificationDTO: {
/** Format: uuid */
id: string;
@@ -1912,15 +2051,38 @@ export interface components {
displayName?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
documentCount?: number;
personType?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
alias?: string;
familyMember?: boolean;
notes?: string;
personType?: string;
/** Format: int64 */
documentCount?: number;
alias?: string;
};
InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"];
label: string;
/** Format: int32 */
hops: number;
};
PersonNodeDTO: {
/** Format: uuid */
id: string;
displayName: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
familyMember: boolean;
};
InferredRelationshipDTO: {
labelFromA: string;
labelFromB: string;
/** Format: int32 */
hops: number;
};
SenderModel: {
/** Format: uuid */
@@ -2021,6 +2183,10 @@ export interface components {
empty?: boolean;
unsorted?: boolean;
};
NetworkDTO: {
nodes: components["schemas"]["PersonNodeDTO"][];
edges: components["schemas"]["RelationshipDTO"][];
};
DocumentVersionSummary: {
/** Format: uuid */
id: string;
@@ -2131,7 +2297,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED";
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -2745,6 +2911,54 @@ export interface operations {
};
};
};
getRelationships: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RelationshipDTO"][];
};
};
};
};
addRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateRelationshipRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["RelationshipDTO"];
};
};
};
};
mergePerson: {
parameters: {
query?: never;
@@ -3491,6 +3705,32 @@ export interface operations {
};
};
};
patchFamilyMember: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["FamilyMemberPatchDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
markOneRead: {
parameters: {
query?: never;
@@ -3889,6 +4129,28 @@ export interface operations {
};
};
};
getInferredRelationships: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["InferredRelationshipWithPersonDTO"][];
};
};
};
};
getPersonDocuments: {
parameters: {
query?: never;
@@ -3935,6 +4197,29 @@ export interface operations {
};
};
};
getRelationshipBetween: {
parameters: {
query?: never;
header?: never;
path: {
aId: string;
bId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["InferredRelationshipDTO"];
};
};
};
};
getTrainingInfo: {
parameters: {
query?: never;
@@ -4150,6 +4435,26 @@ export interface operations {
};
};
};
getNetwork: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["NetworkDTO"];
};
};
};
};
getVersions: {
parameters: {
query?: never;
@@ -4470,7 +4775,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED")[];
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
};
header?: never;
path?: never;
@@ -4571,6 +4876,27 @@ export interface operations {
};
};
};
deleteRelationship: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
relId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeAlias: {
parameters: {
query?: never;

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { m } from '$lib/paraglide/messages.js';
import { chipLabel, otherName } from './relationshipLabels';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const ALICE_ID = 'alice-uuid';
const BOB_ID = 'bob-uuid';
function makeRel(
relationType: RelationshipDTO['relationType'],
override: Partial<RelationshipDTO> = {}
): RelationshipDTO {
return {
id: 'rel-1',
personId: ALICE_ID,
relatedPersonId: BOB_ID,
personDisplayName: 'Alice',
relatedPersonDisplayName: 'Bob',
relationType,
...override
};
}
describe('chipLabel', () => {
it('returns parent_of when perspective is the subject of PARENT_OF', () => {
const rel = makeRel('PARENT_OF');
expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_parent_of());
});
it('returns child_of when perspective is the object of PARENT_OF', () => {
const rel = makeRel('PARENT_OF');
expect(chipLabel(rel, BOB_ID)).toBe(m.relation_child_of());
});
it('returns spouse_of for SPOUSE_OF regardless of perspective', () => {
const rel = makeRel('SPOUSE_OF');
expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_spouse_of());
expect(chipLabel(rel, BOB_ID)).toBe(m.relation_spouse_of());
});
it('returns sibling_of for SIBLING_OF', () => {
expect(chipLabel(makeRel('SIBLING_OF'), ALICE_ID)).toBe(m.relation_sibling_of());
});
it('returns friend for FRIEND', () => {
expect(chipLabel(makeRel('FRIEND'), ALICE_ID)).toBe(m.relation_friend());
});
it('returns other for OTHER', () => {
expect(chipLabel(makeRel('OTHER'), ALICE_ID)).toBe(m.relation_other());
});
});
describe('otherName', () => {
it('returns relatedPersonDisplayName when perspective is the subject', () => {
const rel = makeRel('PARENT_OF');
expect(otherName(rel, ALICE_ID)).toBe('Bob');
});
it('returns personDisplayName when perspective is the object', () => {
const rel = makeRel('PARENT_OF');
expect(otherName(rel, BOB_ID)).toBe('Alice');
});
});

View File

@@ -0,0 +1,77 @@
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
export function chipLabel(rel: RelationshipDTO, perspectivePersonId: string): string {
const viewpointIsSubject = rel.personId === perspectivePersonId;
switch (rel.relationType) {
case 'PARENT_OF':
return viewpointIsSubject ? m.relation_parent_of() : m.relation_child_of();
case 'SPOUSE_OF':
return m.relation_spouse_of();
case 'SIBLING_OF':
return m.relation_sibling_of();
case 'FRIEND':
return m.relation_friend();
case 'COLLEAGUE':
return m.relation_colleague();
case 'EMPLOYER':
return m.relation_employer();
case 'DOCTOR':
return m.relation_doctor();
case 'NEIGHBOR':
return m.relation_neighbor();
default:
return m.relation_other();
}
}
export function otherName(rel: RelationshipDTO, perspectivePersonId: string): string {
return rel.personId === perspectivePersonId
? rel.relatedPersonDisplayName
: rel.personDisplayName;
}
/**
* Maps a backend inferred-label key (parent, uncle_aunt, ...) to its
* localised string. Unknown keys fall back to "distant".
*/
export function inferredRelationshipLabel(key: string): string {
switch (key) {
case 'parent':
return m.relation_inferred_parent();
case 'child':
return m.relation_inferred_child();
case 'spouse':
return m.relation_inferred_spouse();
case 'sibling':
return m.relation_inferred_sibling();
case 'grandparent':
return m.relation_inferred_grandparent();
case 'grandchild':
return m.relation_inferred_grandchild();
case 'great_grandparent':
return m.relation_inferred_great_grandparent();
case 'great_grandchild':
return m.relation_inferred_great_grandchild();
case 'uncle_aunt':
return m.relation_inferred_uncle_aunt();
case 'niece_nephew':
return m.relation_inferred_niece_nephew();
case 'great_uncle_aunt':
return m.relation_inferred_great_uncle_aunt();
case 'great_niece_nephew':
return m.relation_inferred_great_niece_nephew();
case 'inlaw_parent':
return m.relation_inferred_inlaw_parent();
case 'inlaw_child':
return m.relation_inferred_inlaw_child();
case 'sibling_inlaw':
return m.relation_inferred_sibling_inlaw();
case 'cousin_1':
return m.relation_inferred_cousin_1();
default:
return m.relation_inferred_distant();
}
}

View File

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

View File

@@ -131,7 +131,8 @@ describe('DocumentList sender grouping', () => {
id: 's1',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
}
}),
@@ -143,7 +144,8 @@ describe('DocumentList sender grouping', () => {
id: 's2',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
personType: 'PERSON',
familyMember: false
}
}
})
@@ -162,7 +164,8 @@ describe('DocumentList sender grouping', () => {
id: 's1',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON' as const
personType: 'PERSON' as const,
familyMember: false
};
const items = [
makeItem({ document: { ...makeItem().document, id: '1', sender } }),
@@ -191,7 +194,13 @@ describe('DocumentList receiver grouping', () => {
...makeItem().document,
id: '1',
receivers: [
{ id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' }
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
}
]
}
})
@@ -210,8 +219,20 @@ describe('DocumentList receiver grouping', () => {
id: '1',
title: 'Rundbriefchen',
receivers: [
{ id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' },
{ id: 'r2', lastName: 'Meier', displayName: 'Hans Meier', personType: 'PERSON' }
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
},
{
id: 'r2',
lastName: 'Meier',
displayName: 'Hans Meier',
personType: 'PERSON',
familyMember: false
}
]
}
})

View File

@@ -31,7 +31,7 @@ let {
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
@@ -73,7 +73,7 @@ let {
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
@@ -86,7 +86,7 @@ let {
</div>
</div>
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<div class="grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label

View File

@@ -35,6 +35,7 @@ const makePerson = (overrides: Record<string, unknown> = {}) => ({
firstName: 'Hans',
lastName: 'Müller',
personType: 'PERSON' as const,
familyMember: false,
displayName: 'Hans Müller',
...overrides
});

View File

@@ -1,6 +1,7 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import { inferredRelationshipLabel } from '$lib/relationshipLabels';
export async function load({ params, fetch }) {
const { id } = params;
@@ -15,5 +16,38 @@ export async function load({ params, fetch }) {
throw error(docResult.response.status, getErrorMessage(code));
}
return { document: docResult.data! };
const document = docResult.data!;
const inferredRelationship = await loadInferredRelationship(api, document);
return { document, inferredRelationship };
}
async function loadInferredRelationship(
api: ReturnType<typeof createApiClient>,
document: {
sender?: { id: string; familyMember?: boolean } | null;
receivers?: { id: string; familyMember?: boolean }[];
}
): Promise<{ labelFromA: string; labelFromB: string } | null> {
const sender = document.sender;
const receivers = document.receivers ?? [];
// The badge is shown only when both endpoints are family members and the
// document has exactly one receiver.
if (!sender?.familyMember) return null;
if (receivers.length !== 1) return null;
const receiver = receivers[0];
if (!receiver?.familyMember) return null;
const result = await api.GET('/api/persons/{aId}/relationship-to/{bId}', {
params: { path: { aId: sender.id, bId: receiver.id } }
});
if (result.response.status === 404) return null;
if (!result.response.ok || !result.data) return null;
return {
labelFromA: inferredRelationshipLabel(result.data.labelFromA),
labelFromB: inferredRelationshipLabel(result.data.labelFromB)
};
}

View File

@@ -13,9 +13,12 @@ import { getErrorMessage } from '$lib/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
let { data } = $props();
const { confirm } = getConfirmService();
const doc = $derived(data.document);
const canWrite = $derived(data.canWrite ?? false);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
@@ -105,6 +108,26 @@ async function deleteBlock(blockId: string) {
annotationReloadKey++;
}
async function handleAnnotationDeleteRequest(annotationId: string) {
const confirmed = await confirm({
title: m.transcription_block_delete_confirm(),
destructive: true
});
if (!confirmed) return;
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
if (block) {
await deleteBlock(block.id);
} else {
// Annotation has no linked block — delete the annotation directly
const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete annotation failed');
annotationReloadKey++;
}
}
async function reviewToggle(blockId: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
method: 'PUT'
@@ -114,6 +137,18 @@ async function reviewToggle(blockId: string) {
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
}
async function markAllReviewed() {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, {
method: 'PUT'
});
if (!res.ok) return;
const updated = await res.json();
for (const b of updated) {
const existing = transcriptionBlocks.find((x) => x.id === b.id);
if (existing) existing.reviewed = b.reviewed;
}
}
async function toggleTrainingLabel(label: string, enrolled: boolean) {
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
method: 'PATCH',
@@ -360,6 +395,7 @@ onMount(() => {
canWrite={canWrite}
fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode}
inferredRelationship={data.inferredRelationship}
/>
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
@@ -381,6 +417,7 @@ onMount(() => {
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
/>
</div>
@@ -477,6 +514,7 @@ onMount(() => {
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
onReviewToggle={reviewToggle}
onMarkAllReviewed={markAllReviewed}
onTriggerOcr={triggerOcr}
onToggleTrainingLabel={toggleTrainingLabel}
/>

View File

@@ -365,6 +365,11 @@
text-underline-offset: 4px;
}
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
button {
cursor: pointer;
}
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
:focus-visible {
outline: 2px solid var(--c-focus-ring);

View File

@@ -11,11 +11,20 @@ export async function load({ params, fetch, locals }) {
g.permissions.includes('WRITE_ALL')
) ?? false;
const [personResult, sentDocsResult, receivedDocsResult, aliasesResult] = await Promise.all([
const [
personResult,
sentDocsResult,
receivedDocsResult,
aliasesResult,
relsResult,
inferredResult
] = await Promise.all([
api.GET('/api/persons/{id}', { params: { path: { id } } }),
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } })
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } })
]);
if (!personResult.response.ok) {
@@ -28,6 +37,8 @@ export async function load({ params, fetch, locals }) {
sentDocuments: sentDocsResult.data ?? [],
receivedDocuments: receivedDocsResult.data ?? [],
aliases: aliasesResult.data ?? [],
relationships: relsResult.data ?? [],
inferredRelationships: inferredResult.data ?? [],
canWrite
};
}

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