Compare commits

...

17 Commits

Author SHA1 Message Date
Marcel
a5e3205520 fix(stammbaum): make gutter visibility prop-overridable for tests (#689)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m54s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
CI / Unit & Component Tests (push) Successful in 3m49s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 4m14s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m3s
CI kept failing on the two gutter-render tests because the vitest-browser
iframe viewport is narrower than 768 px → window.matchMedia(min-width:
768px) returns false → gutter is hidden → g[role="text"] selector
returns []. The previous synchronous-seed fix was insufficient because
matchMedia itself was the false branch.

Add an optional `showGutter?: boolean` prop. When set, it bypasses the
matchMedia detection — tests pass `showGutter: true` to assert the
rendered gutter, and `showGutter: false` to assert the absent path.
Production callers leave it undefined so the existing media-query
detection still governs visibility.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:53:27 +02:00
Marcel
f124529ee8 fix(stammbaum): seed gutter media-query state synchronously (#689)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m32s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m39s
CI / fail2ban Regex (pull_request) Failing after 45s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
CI flagged two browser tests:

- "renders a G{n} label per occupied generation row …"
- "wraps the visible G3 text inside an aria-labelled group …"

Both queried g[role="text"] and got an empty array. Root cause:
isMdOrUp was initialised to false and only flipped to true inside a
$effect — but $effect runs after the first render, so the test's
post-render DOM scan saw the pre-effect (gutter-absent) state.

Seed the rune synchronously from window.matchMedia(...).matches when
window is available; SSR still picks the false branch and hydrates
without a layout flash. The effect now only attaches the change
listener for subsequent resizes.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:22:09 +02:00
Marcel
61ca5a6e40 test(person): tighten generation null-clear coverage (#689)
Sara's QA concerns:

1. PersonControllerTest.updatePerson_returns200_whenGenerationNull was
   asymmetric — only checked status 200, no body assertion. Now also
   asserts `$.generation` is null in the JSON response, mirroring the
   in-range test's body check.

2. New full-stack PUT→DB→GET round-trip in PersonServiceIntegrationTest
   (updatePerson_clearGenerationToNull_readsBackNullFromDb) seeds a
   person with generation=3, calls updatePerson with generation=null,
   flushes the persistence context, and asserts the column reads back
   null from the DB. Without this we only had the mocked WebMvcTest
   boundary; nothing proved JPA actually wrote SQL NULL.

3. Sibling test (updatePerson_setGenerationToZero_readsBackZeroFromDb)
   pins the G 0 end-to-end so a primitive zero can't silently coerce
   to null anywhere along controller → service → JPA.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:19:13 +02:00
Marcel
516a0a3814 refactor(person): single source of truth for generation bounds (#689)
Markus flagged the 0/10 range was duplicated across five sites (DB
CHECK, both importers, DTO @Min/@Max, dropdown range). New
PersonGeneration.MIN_GENERATION / MAX_GENERATION constants are now
the canonical Java source; the DTO annotations and both importer
guards reference them. The V70 SQL CHECK comment now points at the
Java constants so future widening updates one Java class plus one
SQL literal (Flyway forbids rewriting the migration in place).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:16:26 +02:00
Marcel
39276b179d docs(stammbaum): document gutter + persons.generation column (#689)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m52s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 4m7s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
db-orm.puml: persons gains a generation : SMALLINT attribute mirroring
the V70 column. No FK change, so db-relationships.puml is unaffected.

stammbaum-tree-spec.html:
- impl-ref table: replace "Gen label" with "Gutter label" + new
  "Gutter stripe underlay" rows describing the role="text" wrapper,
  un-shifted source-truth value, and below-md hidden state.
- light + dark colour-table rows updated to "Gutter label" /
  "Gutter stripe" with the new var(--c-ink-2) / var(--c-gutter-stripe)
  swatches.
- "Generationen ▾" filter chip mocks removed from desktop and tablet
  layout sections (the filter UI was de-scoped from this PR).

Inline visual mockup SVGs that still show pre-gutter labelling are
out of scope per the issue body — the impl-ref table is the
authoritative source for this PR.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:57:54 +02:00
Marcel
577dd3fcb1 feat(person): generation dropdown on Person edit/new forms (#689)
PersonEditForm.svelte gains a G 0…G 6 select inside the {#if isPerson}
block. min-h-[44px] meets WCAG 2.5.8 / dual-audience touch target.
generationStr is initialised via $state(untrack(...)) so prop reruns
never reset an in-progress edit (same pattern as selectedType).

Both /persons/[id]/edit and /persons/new form actions read the field
without the conditional-spread idiom — generation always lands in the
PUT/POST body. G 0 is a valid family-tree-root value the spread would
silently drop, and an empty option sends null so a human can clear the
field back to "unset".

i18n adds person_label_generation / person_option_generation_unset /
person_hint_generation in de/en/es. Drops the dead stammbaum_generations
key (zero callsites after the filter-chip removal in the spec).

Tests: dropdown render + hydration in the component, generation=0/3/null
arriving in the API body in the server actions.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:55:25 +02:00
Marcel
c0b500b692 feat(stammbaum): render generation gutter on the family tree (#689)
The gutter sits 100 px to the left of the tree canvas on md+ viewports
(hidden entirely below md to preserve scrollable area on phones — see
spec's deliberate dual-audience trade-off). Per occupied generation
row it draws:

- A full-width decorative stripe rect alternating transparent and
  var(--c-gutter-stripe). aria-hidden because it carries no meaning.
- The label `G{n}` at the left edge, sourced from the un-shifted
  node.generation value (never the post-normalise rank), wrapped in
  `<g role="text" aria-label="Generation N">` so screen readers
  announce the full word instead of "G three".

CSS adds --c-gutter-stripe in both the light root and the dark mode
blocks (8% / 14% mint over canvas — decorative contrast carve-out).

Browser tests cover label rendering, the ARIA wrapper, and the
viewport-below-md absent-gutter path via a matchMedia stub. Existing
StammbaumTree structural-invariant tests still pass since none of
them assert anything inside the gutter region.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:49:23 +02:00
Marcel
cb8c85a742 feat(stammbaum): seed layout rank from imported generation (#689)
buildLayout switches to a two-stage assignment:

1. Seed — every node with node.generation != null is locked at that
   rank. The fallback heuristic never moves a locked rank, and the
   spouse-pulldown never pulls a locked rank.
2. Fallback — for unseeded nodes, rank = max(parent rank) + 1 reading
   parents from the same unified rank map, so an unseeded child of a
   seeded G 2 parent correctly inherits rank 3. Spouse-pulldown ties
   unseeded spouses to their deeper partner exactly as before.
3. Normalise — if any rank is negative (future G −1 ancestor), shift
   the whole map so min(rank) == 0. No-op for today's data.

Fixes the Herbert Cram pattern from #361's review: two parented
spouses with imported G 3 now render on the same y row. Existing
StammbaumTree tests still pass byte-for-byte because every test node
has node.generation undefined, so the heuristic runs unchanged.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:43:58 +02:00
Marcel
c93d3b03ed chore(api): mirror generation field in api types + PersonFormData (#689)
Manually mirrors the Spring Boot @Schema additions on PersonNodeDTO,
Person, and PersonUpdateDTO into the generated api.ts so the form +
gutter components compile against a finished type surface. The next
backend dev-profile run + `npm run generate:api` will regenerate the
same shape from the live OpenAPI spec.

PersonFormData gains `generation?: number | null` so PersonEditForm's
$state initialiser typechecks.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:41:18 +02:00
Marcel
8f163f9b77 feat(import): warn on generation monotonicity violations (#689)
Inject RelationshipService into CanonicalImportOrchestrator and walk
PARENT_OF edges in the family network after both person loaders finish
(before documents). For every edge where child.generation is set and
not strictly deeper than parent.generation, log a WARN — soft check,
never fails the batch.

Reads through getFamilyNetwork() per the layering rule (orchestrator
never touches PersonRelationshipRepository directly). Curators see the
warning in the import log; the rest of the pipeline is unaffected so
data with curatorial gaps still loads cleanly.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:39:29 +02:00
Marcel
40511535eb feat(relationship): add generation to PersonNodeDTO + update all sites (#689)
PersonNodeDTO is a positional record. The optional Integer generation
field is inserted between deathYear and familyMember so all four
construction sites stay readable without a builder.

- RelationshipService.getFamilyNetwork → populates with
  person.getGeneration() (the Stammbaum's strict-rank source on the
  frontend).
- RelationshipInferenceService.findAllFor → populates the same way;
  inference UI does not consume it but the field travels along for
  consistency.
- RelationshipControllerTest fixtures pass null.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:35:40 +02:00
Marcel
a68a822c13 feat(import): pass generation from JSON in PersonTreeImporter (#689)
Reads the optional `generation` integer from the canonical tree JSON and
routes it into PersonUpsertCommand. Out-of-range values are skip-and-
warned with the same policy as the register importer.

Tree imports run after register (per CanonicalImportOrchestrator); a
tree-confirmed integer overwrites a register-parsed value — both sides
are "canonical" in preferHuman terms (neither is a human edit).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:32:27 +02:00
Marcel
df0037cba2 feat(import): parse generation column in PersonRegisterImporter (#689)
Reads the optional `generation` cell by header name (REQUIRED_HEADERS is
not extended — REQ-IMP-001 backward-compat for older artifacts), parses
it through GENERATION_PATTERN (^\s*G?\s*(-?\d+)), and routes it into
PersonUpsertCommand.generation.

Out-of-range values (G 99, G -1) are skip-and-warned, never abort the
batch; the post-parse range guard mirrors the V70 CHECK constraint so
the DB never sees a value Bean Validation wouldn't accept.

Pinned with a parametrised CsvSource covering every shape from the
acceptance criteria plus a backward-compat test (artifact without a
generation column still imports, all upserts get generation=null).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:30:31 +02:00
Marcel
dcb5585c64 feat(person): route generation through service write paths (#689)
- fromCanonical writes the imported generation into a new Person row.
- mergeCanonical routes existing/canonical generation through the
  existing preferHuman(Integer, Integer) overload so a human-edited
  value is never overwritten on re-import (ADR-025).
- updatePerson writes generation verbatim from the form DTO so a human
  can clear it back to null — same shape as birthYear/deathYear.
- createPerson(PersonUpdateDTO) writes generation so /persons/new flow
  doesn't silently drop a selected G value on create.

Pinned with five tests covering the four write paths plus the
documenting test that captures preferHuman's known limitation
(explicit human null is overwritten by a non-null canonical value —
same as birthYear/deathYear, deferred to a future helper rework if it
ever produces a user-visible bug).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:27:11 +02:00
Marcel
1e77d6d98c feat(person): generation on PersonUpsertCommand + PersonUpdateDTO (#689)
Adds the optional generation field to both DTOs:

- PersonUpsertCommand gains Integer generation in the canonical-import
  builder chain; service wiring lands in the next commit.
- PersonUpdateDTO gains @Min(0)@Max(10) Integer generation, the form-path
  surface. The constraints mirror the V70 CHECK so validation fails fast
  at the controller before reaching the DB.

PersonControllerTest pins the validation behaviour: -1 → 400, 11 → 400,
null → 200, 3 → 200 for both PUT (update) and POST (create) paths. The
GlobalExceptionHandler maps MethodArgumentNotValidException to
VALIDATION_ERROR so the frontend's extractErrorCode keeps working.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:23:38 +02:00
Marcel
f22508ca91 feat(person): add nullable generation column to persons (#689)
Flyway V70: SMALLINT generation column with CHECK(0..10) and partial
index over non-null rows. Person.generation field surfaces it through
the JPA model. Pre-import rows and persons outside the curated family
graph legitimately stay null; the canonical importer (next commits)
back-fills via preferHuman so a human-edited value is never lost.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:20:24 +02:00
Marcel
1cb05697cc refactor(stammbaum): extract buildLayout to pure module
Move the layout function out of StammbaumTree.svelte (lines 47-275) into a
new pure TypeScript module at frontend/src/lib/person/genealogy/layout/
buildLayout.ts so it can be exercised by direct unit tests. Drops the
eslint-disable svelte/prefer-svelte-reactivity blanket; switches the
remaining scope-local Maps/Sets in parentLinks to SvelteMap/SvelteSet to
satisfy the rule per-call-site. No behaviour change — existing
StammbaumTree tests must pass byte-for-byte.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:17:18 +02:00
40 changed files with 1489 additions and 285 deletions

View File

@@ -4,13 +4,21 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.File;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Runs the four canonical loaders in their real dependency order — encoded explicitly
@@ -34,6 +42,7 @@ public class CanonicalImportOrchestrator {
private final PersonRegisterImporter personRegisterImporter;
private final PersonTreeImporter personTreeImporter;
private final DocumentImporter documentImporter;
private final RelationshipService relationshipService;
@Value("${app.import.dir:/import}")
private String canonicalDir;
@@ -67,6 +76,7 @@ public class CanonicalImportOrchestrator {
tagTreeImporter.load(tagTree);
personRegisterImporter.load(persons);
personTreeImporter.load(personsTree);
warnOnGenerationMonotonicityViolations();
DocumentImporter.LoadResult result = documentImporter.load(documents);
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
@@ -91,4 +101,31 @@ public class CanonicalImportOrchestrator {
}
return artifact;
}
/**
* Walks every PARENT_OF edge in the family graph and logs a WARN whenever a child's
* generation is not strictly deeper than its parent's. Soft check only — the import
* is never aborted; the warning is a forensic signal for the curator. Reads through
* {@link RelationshipService} so the orchestrator stays within the layering rule
* (no direct repository access).
*/
private void warnOnGenerationMonotonicityViolations() {
NetworkDTO network = relationshipService.getFamilyNetwork();
Map<UUID, PersonNodeDTO> byId = new HashMap<>(network.nodes().size());
for (PersonNodeDTO node : network.nodes()) {
byId.put(node.id(), node);
}
for (RelationshipDTO edge : network.edges()) {
if (edge.relationType() != RelationType.PARENT_OF) continue;
PersonNodeDTO parent = byId.get(edge.personId());
PersonNodeDTO child = byId.get(edge.relatedPersonId());
if (parent == null || child == null) continue;
Integer pg = parent.generation();
Integer cg = child.generation();
if (pg != null && cg != null && cg <= pg) {
log.warn("Generation monotonicity violation: parent {} (G{}) -> child {} (G{})",
parent.displayName(), pg, child.displayName(), cg);
}
}
}
}

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.importing;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.person.PersonGeneration;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
@@ -11,6 +12,8 @@ import java.io.File;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
@@ -25,6 +28,13 @@ public class PersonRegisterImporter {
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
// Matches a leading optional G then a signed integer. Anchored at the
// start so noise can't slip in before the number, but tolerant of trailing
// commentary cells (e.g. "G 2 de Gruyter") since curated rows sometimes
// carry an inline note. Out-of-range values are caught by the post-parse
// range guard, not by the regex.
private static final Pattern GENERATION_PATTERN = Pattern.compile("^\\s*G?\\s*(-?\\d+)");
private final PersonService personService;
public int load(File artifact) {
@@ -49,11 +59,31 @@ public class PersonRegisterImporter {
.notes(blankToNull(row.get("notes")))
.birthYear(yearOf(row.get("birth_date")))
.deathYear(yearOf(row.get("death_date")))
.generation(parseGeneration(row.get("generation"), personId))
.personType(PersonType.PERSON)
.provisional(Boolean.parseBoolean(row.get("provisional")))
.build();
}
/**
* Parses an optional {@code G n} generation cell. Returns null for blanks,
* non-matching strings, and any value outside the {@link PersonGeneration}
* bounds (mirroring the V70 CHECK). Out-of-range values log a WARN but
* never abort the batch — REQ-IMP-001.
*/
static Integer parseGeneration(String raw, String personId) {
if (raw == null || raw.isBlank()) return null;
Matcher m = GENERATION_PATTERN.matcher(raw);
if (!m.find()) return null;
int parsed = Integer.parseInt(m.group(1));
if (parsed < PersonGeneration.MIN_GENERATION || parsed > PersonGeneration.MAX_GENERATION) {
log.warn("Skipping out-of-range generation '{}' for row {}", raw, personId);
return null;
}
log.debug("Parsed generation '{}' for person {}", raw, personId);
return parsed;
}
private static Integer yearOf(String isoDate) {
if (isoDate == null || isoDate.isBlank()) return null;
try {

View File

@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonGeneration;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
@@ -79,12 +80,29 @@ public class PersonTreeImporter {
.notes(blankToNull(text(node, "notes")))
.birthYear(intOrNull(node, "birthYear"))
.deathYear(intOrNull(node, "deathYear"))
.generation(generationOrNull(node, personId))
.familyMember(node.path("familyMember").asBoolean(false))
.personType(PersonType.PERSON)
.provisional(false)
.build();
}
/**
* Returns the JSON {@code generation} value if present and within the
* {@link PersonGeneration} bounds; null otherwise. Out-of-range values
* log a WARN but never abort the batch — mirrors the register-importer
* skip-and-warn policy.
*/
private static Integer generationOrNull(JsonNode node, String personId) {
Integer raw = intOrNull(node, "generation");
if (raw == null) return null;
if (raw < PersonGeneration.MIN_GENERATION || raw > PersonGeneration.MAX_GENERATION) {
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
return null;
}
return raw;
}
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
int created = 0;
for (JsonNode node : relationships) {

View File

@@ -52,6 +52,13 @@ public class Person {
private Integer birthYear;
private Integer deathYear;
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
// Nullable for persons outside the curated family graph. Drives the
// Stammbaum strict-rank seed (see #689) and re-import preserves human
// edits via PersonService.preferHuman (ADR-025).
@Column(name = "generation")
private Integer generation;
@Column(name = "family_member", nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.person;
/**
* Single source of truth for the {@code persons.generation} value range.
* The DB CHECK in V70, the {@code PersonUpdateDTO} Bean Validation annotations,
* and the canonical importers all reference these constants so a future widening
* (e.g. accepting {@code G 1} ancestors) happens in one place. Mirror this file
* by hand in the V70 migration comment when adjusting bounds.
*/
public final class PersonGeneration {
public static final int MIN_GENERATION = 0;
public static final int MAX_GENERATION = 10;
private PersonGeneration() {}
}

View File

@@ -177,6 +177,7 @@ public class PersonService {
.notes(blankToNull(cmd.notes()))
.birthYear(cmd.birthYear())
.deathYear(cmd.deathYear())
.generation(cmd.generation())
.familyMember(cmd.familyMember())
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
.provisional(cmd.provisional())
@@ -200,6 +201,7 @@ public class PersonService {
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
existing.setPersonType(cmd.personType());
}
@@ -254,6 +256,7 @@ public class PersonService {
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthYear(dto.getBirthYear())
.deathYear(dto.getDeathYear())
.generation(dto.getGeneration())
.build();
return personRepository.save(person);
}
@@ -286,6 +289,9 @@ public class PersonService {
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
// Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration());
return personRepository.save(person);
}

View File

@@ -1,5 +1,7 @@
package org.raddatz.familienarchiv.person;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -21,4 +23,9 @@ public class PersonUpdateDTO {
private String notes;
private Integer birthYear;
private Integer deathYear;
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
// PersonGeneration so DB, DTO, and importer all read from one place.
@Min(PersonGeneration.MIN_GENERATION)
@Max(PersonGeneration.MAX_GENERATION)
private Integer generation;
}

View File

@@ -18,6 +18,7 @@ public record PersonUpsertCommand(
String notes,
Integer birthYear,
Integer deathYear,
Integer generation,
boolean familyMember,
PersonType personType,
boolean provisional

View File

@@ -96,7 +96,8 @@ public class RelationshipInferenceService {
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getGeneration(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)

View File

@@ -66,7 +66,8 @@ public class RelationshipService {
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getGeneration(), true));
}
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(

View File

@@ -10,5 +10,6 @@ public record PersonNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
Integer birthYear,
Integer deathYear,
Integer generation,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
) {}

View File

@@ -0,0 +1,26 @@
-- #689: persist the hand-curated "G 0…G 5" generation index from
-- canonical-persons.xlsx so the Stammbaum layout can use it as a strict
-- rank anchor (replacing the current iterative longest-path heuristic that
-- silently misplaces loose spouses with their own parents in the graph).
--
-- Nullable: pre-import rows and persons outside the curated family graph
-- legitimately have no generation. The canonical importer back-fills via
-- preferHuman on the next run; a human-edited value is never overwritten
-- (see ADR-025).
ALTER TABLE persons ADD COLUMN generation SMALLINT;
-- Allowlist of valid generation indices. The 0..10 bounds mirror
-- PersonGeneration.MIN_GENERATION / MAX_GENERATION in Java — keep the
-- two in sync (the DTO @Min/@Max and both importer range guards read from
-- those Java constants). Current data tops out at G 5, but a future G 6 →
-- G 10 widening needs no migration. A G 1 ancestor would require a
-- separate one-shot shift migration (out of scope here; the layout's
-- normalise step already handles negative seeds at render time).
ALTER TABLE persons ADD CONSTRAINT chk_generation_range
CHECK (generation IS NULL OR generation BETWEEN 0 AND 10);
-- Partial index: only the curated rows (≈ 163 of 1,105) ever get a value,
-- and the layout only ever queries for non-null rows.
CREATE INDEX idx_persons_generation ON persons (generation)
WHERE generation IS NOT NULL;

View File

@@ -7,12 +7,18 @@ import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.relationship.RelationType;
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -29,10 +35,12 @@ class CanonicalImportOrchestratorTest {
@Mock PersonRegisterImporter personRegisterImporter;
@Mock PersonTreeImporter personTreeImporter;
@Mock DocumentImporter documentImporter;
@Mock RelationshipService relationshipService;
private CanonicalImportOrchestrator orchestrator(Path dir) {
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter);
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter,
relationshipService);
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
return o;
}
@@ -53,6 +61,7 @@ class CanonicalImportOrchestratorTest {
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
@@ -68,6 +77,7 @@ class CanonicalImportOrchestratorTest {
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(3, List.of()));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
@@ -118,6 +128,7 @@ class CanonicalImportOrchestratorTest {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
List.of(new ImportStatus.SkippedFile("fake.pdf", ImportStatus.SkipReason.INVALID_PDF_SIGNATURE))));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
@@ -127,4 +138,46 @@ class CanonicalImportOrchestratorTest {
.extracting(ImportStatus.SkippedFile::filename)
.containsExactly("fake.pdf");
}
// ─── generation monotonicity soft-check (#689) ─────────────────────────────
@Test
void runImport_invokesGetFamilyNetwork_afterPersonLoaders_beforeDocuments(@TempDir Path dir) throws Exception {
writeAllArtifacts(dir);
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
InOrder order = inOrder(personRegisterImporter, personTreeImporter, relationshipService, documentImporter);
order.verify(personRegisterImporter).load(any());
order.verify(personTreeImporter).load(any());
order.verify(relationshipService).getFamilyNetwork();
order.verify(documentImporter).load(any());
}
@Test
void runImport_completes_evenWhenMonotonicityViolatingEdgePresent(@TempDir Path dir) throws Exception {
// child.generation (2) <= parent.generation (3) — monotonicity violation.
// The orchestrator must WARN and continue; it must not abort or fail-closed.
writeAllArtifacts(dir);
UUID parentId = UUID.randomUUID();
UUID childId = UUID.randomUUID();
PersonNodeDTO parent = new PersonNodeDTO(parentId, "Parent", null, null, 3, true);
PersonNodeDTO child = new PersonNodeDTO(childId, "Child", null, null, 2, true);
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), parentId, childId,
"Parent", null, null, "Child", null, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
CanonicalImportOrchestrator o = orchestrator(dir);
o.runImport();
assertThat(o.getStatus().state()).isEqualTo(ImportStatus.State.DONE);
verify(documentImporter).load(any());
}
}

View File

@@ -6,6 +6,8 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.person.Person;
@@ -87,6 +89,50 @@ class PersonRegisterImporterTest {
assertThat(processed).isEqualTo(2);
}
// ─── generation parsing (#689) ─────────────────────────────────────────────
@ParameterizedTest
@CsvSource(value = {
"'G 3', 3",
"'G3', 3",
"'G 3', 3",
"'3', 3",
"' 3 ', 3",
"'G 2 de Gruyter', 2",
"'', null",
"'garbage', null",
"'G 99', null",
"'G -1', null"
}, nullValues = "null")
void load_parsesGeneration_perRegex(String raw, Integer expected, @TempDir Path tempDir) throws Exception {
PersonService personService = mock(PersonService.class);
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
Path xlsx = writePersonsWithGeneration(tempDir,
rowWithGeneration("herbert-cram", "Cram", "Herbert", "", "", "False", raw));
new PersonRegisterImporter(personService).load(xlsx.toFile());
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
verify(personService).upsertBySourceRef(captor.capture());
assertThat(captor.getValue().generation()).isEqualTo(expected);
}
@Test
void load_succeeds_andLeavesGenerationNull_whenArtifactHasNoGenerationColumn(@TempDir Path tempDir) throws Exception {
// REQ-IMP-001: older artifacts without the `generation` column must still
// import. REQUIRED_HEADERS is intentionally not extended.
PersonService personService = mock(PersonService.class);
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
Path xlsx = writePersons(tempDir, row(
"old-artifact", "Mueller", "Hans", "", "", "False"));
new PersonRegisterImporter(personService).load(xlsx.toFile());
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
verify(personService).upsertBySourceRef(captor.capture());
assertThat(captor.getValue().generation()).isNull();
}
private static Person personOf(PersonUpsertCommand cmd) {
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef())
.firstName(cmd.firstName()).lastName(cmd.lastName())
@@ -127,4 +173,36 @@ class PersonRegisterImporterTest {
}
return xlsx;
}
private Map<String, String> rowWithGeneration(String personId, String lastName, String firstName,
String maidenName, String notes, String provisional,
String generation) {
Map<String, String> r = row(personId, lastName, firstName, maidenName, notes, provisional);
r.put("generation", generation);
return r;
}
@SafeVarargs
private Path writePersonsWithGeneration(Path dir, Map<String, String>... rows) throws Exception {
Path xlsx = dir.resolve("canonical-persons.xlsx");
List<String> headers = List.of(
"person_id", "last_name", "first_name", "maiden_name", "notes", "provisional", "generation");
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet1");
Row header = sheet.createRow(0);
for (int i = 0; i < headers.size(); i++) {
header.createCell(i).setCellValue(headers.get(i));
}
for (int r = 0; r < rows.length; r++) {
Row row = sheet.createRow(r + 1);
for (int c = 0; c < headers.size(); c++) {
row.createCell(c).setCellValue(rows[r].getOrDefault(headers.get(c), ""));
}
}
try (OutputStream out = Files.newOutputStream(xlsx)) {
wb.write(out);
}
}
return xlsx;
}
}

View File

@@ -151,6 +151,65 @@ class PersonTreeImporterTest {
verify(relationshipService, org.mockito.Mockito.never()).addRelationship(any(), any());
}
// ─── generation (#689) ─────────────────────────────────────────────────────
@Test
void load_passesGenerationFromJson(@TempDir Path tempDir) throws Exception {
PersonService personService = mock(PersonService.class);
RelationshipService relationshipService = mock(RelationshipService.class);
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
Path json = write(tempDir, """
{"persons":[
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
"personId":"herbert-cram","generation":3}
],"relationships":[]}
""");
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
verify(personService).upsertBySourceRef(captor.capture());
assertThat(captor.getValue().generation()).isEqualTo(3);
}
@Test
void load_returnsNullGeneration_whenAbsentFromJson(@TempDir Path tempDir) throws Exception {
PersonService personService = mock(PersonService.class);
RelationshipService relationshipService = mock(RelationshipService.class);
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
Path json = write(tempDir, """
{"persons":[
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
"personId":"herbert-cram"}
],"relationships":[]}
""");
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
verify(personService).upsertBySourceRef(captor.capture());
assertThat(captor.getValue().generation()).isNull();
}
@Test
void load_skipsOutOfRangeGeneration_logsWarn_neverAborts(@TempDir Path tempDir) throws Exception {
PersonService personService = mock(PersonService.class);
RelationshipService relationshipService = mock(RelationshipService.class);
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
Path json = write(tempDir, """
{"persons":[
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
"personId":"herbert-cram","generation":99}
],"relationships":[]}
""");
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
verify(personService).upsertBySourceRef(captor.capture());
assertThat(captor.getValue().generation()).isNull();
}
private static Person personOf(PersonUpsertCommand cmd) {
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
}

View File

@@ -718,4 +718,74 @@ class PersonControllerTest {
.content("{\"lastName\":\"de Gruyter\"}"))
.andExpect(status().isBadRequest());
}
// ─── generation field validation (#689) ────────────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenGenerationAboveRange() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":11}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenGenerationBelowRange() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":-1}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns200_whenGenerationNull() throws Exception {
// Symmetric body assertion: the response must echo generation as null (not
// absent), so the frontend re-hydrates the "(none)" option after a clear.
// Without this, the in-range test below would be the only end-to-end proof
// that the field flows through the controller.
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.updatePerson(any(), any())).thenReturn(saved);
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.generation").value(org.hamcrest.Matchers.nullValue()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns200_whenGenerationInRange() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
when(personService.updatePerson(any(), any())).thenReturn(saved);
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":3}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.generation").value(3));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_whenGenerationInRange() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
+ "\"personType\":\"PERSON\",\"generation\":3}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.generation").value(3));
}
}

View File

@@ -148,4 +148,55 @@ class PersonImportUpsertTest {
assertThat(result.isProvisional()).isTrue();
}
// ─── generation (#689) ─────────────────────────────────────────────────────
@Test
void upsertBySourceRef_writesGeneration_onFirstImport() {
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
.generation(3).personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getGeneration()).isEqualTo(3);
}
@Test
void upsertBySourceRef_preservesHumanEditedGeneration_onReimport() {
Person humanEdited = Person.builder()
.id(UUID.randomUUID()).sourceRef("herbert-cram")
.firstName("Herbert").lastName("Cram").generation(4).build();
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.of(humanEdited));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
.generation(2).personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getGeneration()).isEqualTo(4);
}
@Test
void mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation() {
// If preferHuman gains explicit-null-vs-unset semantics, delete this test (see issue #689).
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("herbert-cram")
.firstName("Herbert").lastName("Cram").generation(null).build();
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
.generation(3).personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getGeneration()).isEqualTo(3);
}
}

View File

@@ -672,4 +672,39 @@ class PersonRepositoryTest {
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
}
// ─── generation column (#689) ──────────────────────────────────────────────
@Test
void save_persistsGeneration_andFindByIdReturnsSameGeneration() {
Person person = Person.builder()
.firstName("Walter")
.lastName("Raddatz")
.generation(3)
.build();
Person saved = personRepository.save(person);
entityManager.flush();
entityManager.clear();
Optional<Person> found = personRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getGeneration()).isEqualTo(3);
}
@Test
void save_allowsNullGeneration_existingRowsRemainNull() {
Person person = Person.builder()
.firstName("Anonym")
.lastName("Person")
.build();
Person saved = personRepository.save(person);
entityManager.flush();
entityManager.clear();
Optional<Person> found = personRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getGeneration()).isNull();
}
}

View File

@@ -124,6 +124,59 @@ class PersonServiceIntegrationTest {
assertThat(personRepository.findById(target.getId())).isEmpty();
}
// ─── generation full-stack round-trip (#689) ──────────────────────────────
@Test
void updatePerson_clearGenerationToNull_readsBackNullFromDb() {
// Sara's QA concern: pin the full PUT→DB→GET round-trip for the
// null-clear path. Without this we only have the WebMvcTest mocked
// boundary; nothing proved the JPA flush actually wrote SQL NULL.
Person seeded = personRepository.save(Person.builder()
.firstName("Hans").lastName("Raddatz")
.personType(PersonType.PERSON).generation(3).build());
entityManager.flush();
entityManager.clear();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setFirstName("Hans");
dto.setLastName("Raddatz");
dto.setGeneration(null);
personService.updatePerson(seeded.getId(), dto);
entityManager.flush();
entityManager.clear();
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
assertThat(reloaded.getGeneration()).isNull();
}
@Test
void updatePerson_setGenerationToZero_readsBackZeroFromDb() {
// Pin the G 0 case end-to-end. The form-action spec covers that 0
// doesn't get spread-dropped at the SvelteKit boundary; this test
// covers that the controller + service + JPA chain preserves the
// primitive zero (not coerced to null somewhere along the way).
Person seeded = personRepository.save(Person.builder()
.firstName("Walter").lastName("Raddatz")
.personType(PersonType.PERSON).build());
entityManager.flush();
entityManager.clear();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setPersonType(PersonType.PERSON);
dto.setFirstName("Walter");
dto.setLastName("Raddatz");
dto.setGeneration(0);
personService.updatePerson(seeded.getId(), dto);
entityManager.flush();
entityManager.clear();
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
assertThat(reloaded.getGeneration()).isEqualTo(0);
}
@Test
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
// A person referenced as BOTH a document sender and a document receiver must delete

View File

@@ -261,6 +261,54 @@ class PersonServiceTest {
.isEqualTo(400);
}
@Test
void createPerson_dto_persistsGeneration() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
dto.setPersonType(PersonType.PERSON); dto.setGeneration(3);
Person result = personService.createPerson(dto);
assertThat(result.getGeneration()).isEqualTo(3);
}
@Test
void updatePerson_writesGeneration_includingExplicitNullClear() {
// The form path is the only place a human can clear generation back to null.
UUID id = UUID.randomUUID();
Person existing = Person.builder().id(id).firstName("Hans").lastName("Raddatz")
.personType(PersonType.PERSON).generation(3).build();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
dto.setPersonType(PersonType.PERSON); dto.setGeneration(null);
Person result = personService.updatePerson(id, dto);
assertThat(result.getGeneration()).isNull();
}
@Test
void updatePerson_writesGeneration_whenSet() {
UUID id = UUID.randomUUID();
Person existing = Person.builder().id(id).firstName("Hans").lastName("Raddatz")
.personType(PersonType.PERSON).build();
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
dto.setPersonType(PersonType.PERSON); dto.setGeneration(2);
Person result = personService.updatePerson(id, dto);
assertThat(result.getGeneration()).isEqualTo(2);
}
// ─── updatePerson (personType) ───────────────────────────────────────────
@Test

View File

@@ -93,7 +93,7 @@ class RelationshipControllerTest {
@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);
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, null, true);
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980,
@@ -111,7 +111,7 @@ class RelationshipControllerTest {
@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);
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, null, true);
InferredRelationshipWithPersonDTO inferred =
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
when(relationshipService.getInferredRelationships(PERSON_ID))

View File

@@ -237,6 +237,22 @@ class RelationshipServiceTest {
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
}
@Test
void getFamilyNetwork_populates_generation_on_PersonNodeDTO() {
Person walter = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
.familyMember(true).generation(2).build();
Person clara = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
.familyMember(true).generation(3).build();
when(personService.findAllFamilyMembers()).thenReturn(List.of(walter, clara));
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of());
NetworkDTO result = service.getFamilyNetwork();
assertThat(result.nodes()).hasSize(2);
assertThat(result.nodes().stream().map(n -> n.generation()).toList())
.containsExactlyInAnyOrder(2, 3);
}
// --- helpers ---
private static Person person(String name) {

View File

@@ -186,6 +186,7 @@ package "Persons" {
notes : TEXT
birth_year : INTEGER
death_year : INTEGER
generation : SMALLINT
family_member : BOOLEAN NOT NULL
source_ref : VARCHAR(255) UNIQUE
provisional : BOOLEAN NOT NULL

View File

@@ -244,8 +244,12 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<td><span class="swatch" style="background:rgba(255,255,255,.6);border:1px solid #ccc"></span>rgba(255,255,255,.6) — decorative, aria-hidden years</td>
</tr>
<tr>
<td>Gen label</td>
<td><span class="swatch" style="background:#6b7280"></span>#6b72808 px, tracking 2 px, aria-hidden</td>
<td>Gutter label</td>
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 (var(--c-ink-2))12 px, tracking 0.08 em, font-weight 700, screen-reader-labelled</td>
</tr>
<tr>
<td>Gutter stripe</td>
<td><span class="swatch" style="background:rgba(161,220,216,.08);border:1px solid #ccc"></span>rgba(161,220,216,.08) — decorative full-row underlay alternating with transparent</td>
</tr>
<tr>
<td>Panel surface</td>
@@ -302,8 +306,12 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<td><span class="swatch" style="background:#8b97a5;border:1px solid #444"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7">7.1:1 AAA ✓</span></td>
</tr>
<tr>
<td>Gen label</td>
<td><span class="swatch" style="background:#4e6070;border:1px solid #333"></span>#4e6070 — aria-hidden</td>
<td>Gutter label</td>
<td><span class="swatch" style="background:#9ca3af;border:1px solid #333"></span>#9ca3af (var(--c-ink-2)) — 12 px, tracking 0.08 em, font-weight 700, screen-reader-labelled</td>
</tr>
<tr>
<td>Gutter stripe</td>
<td><span class="swatch" style="background:rgba(161,220,216,.14);border:1px solid #333"></span>rgba(161,220,216,.14) — decorative full-row underlay alternating with transparent</td>
</tr>
<tr>
<td>Panel surface</td>
@@ -355,7 +363,6 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<div class="stb-page-head">
<div class="stb-page-title">Stammbaum</div>
<div class="stb-controls">
<div class="stb-btn outline" style="font-size:8px">Generationen ▾</div>
<div class="stb-zoom">
<div class="stb-zoom-btn"></div>
<div class="stb-zoom-btn">+</div>
@@ -507,7 +514,6 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<div class="stb-page-head">
<div class="stb-page-title">Stammbaum</div>
<div class="stb-controls">
<div class="stb-btn outline-dark" style="font-size:8px">Generationen ▾</div>
<div class="stb-zoom">
<div class="stb-zoom-btn"></div>
<div class="stb-zoom-btn">+</div>
@@ -751,7 +757,6 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<div class="stb-page-head">
<div class="stb-page-title">Stammbaum</div>
<div class="stb-controls">
<div class="stb-btn outline" style="font-size:8px">Generationen ▾</div>
<div class="stb-zoom">
<div class="stb-zoom-btn"></div>
<div class="stb-zoom-btn">+</div>
@@ -998,10 +1003,16 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<td>Filled circle at connector midpoints; same color as connectors</td>
</tr>
<tr>
<td>Gen label</td>
<td><code>text-[8px] tracking-[2px] uppercase</code></td>
<td>8 px</td>
<td><code>aria-hidden="true"</code>; #6b7280 light · #4e6070 dark</td>
<td>Gutter label</td>
<td><code>font-sans text-[12px] font-bold tracking-[0.08em] uppercase</code></td>
<td>12 px</td>
<td>Wrapped in <code>&lt;g role="text" aria-label="Generation N"&gt;</code> so screen readers announce the full word. Fill <code>var(--c-ink-2)</code>. Renders the un-shifted <code>node.generation</code> value, never the post-normalise rank.</td>
</tr>
<tr>
<td>Gutter stripe underlay</td>
<td>SVG <code>&lt;rect&gt;</code> aria-hidden, alternating <code>transparent</code> / <code>var(--c-gutter-stripe)</code></td>
<td>NODE_H + ROW_GAP tall, full viewBox width</td>
<td>Decorative band per occupied generation row. Hidden entirely below <code>md</code> (GUTTER_WIDTH = 0).</td>
</tr>
<tr>
<td>Node name text</td>

View File

@@ -175,6 +175,9 @@
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
"person_label_birth_year": "Geburtsjahr",
"person_label_death_year": "Todesjahr",
"person_label_generation": "Generation",
"person_option_generation_unset": "(keine)",
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
"person_placeholder_year": "z.B. 1923",
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
@@ -1103,7 +1106,6 @@
"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.",

View File

@@ -175,6 +175,9 @@
"person_placeholder_notes": "Biographical notes, remarks…",
"person_label_birth_year": "Birth year",
"person_label_death_year": "Death year",
"person_label_generation": "Generation",
"person_option_generation_unset": "(none)",
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
"person_placeholder_year": "e.g. 1923",
"person_year_error": "Please enter a four-digit year",
"person_years_error_order": "Birth year must be before death year",
@@ -1103,7 +1106,6 @@
"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.",

View File

@@ -175,6 +175,9 @@
"person_placeholder_notes": "Notas biográficas, observaciones…",
"person_label_birth_year": "Año de nacimiento",
"person_label_death_year": "Año de fallecimiento",
"person_label_generation": "Generación",
"person_option_generation_unset": "(ninguna)",
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
"person_placeholder_year": "p.ej. 1923",
"person_year_error": "Introduzca un año de cuatro dígitos",
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
@@ -1103,7 +1106,6 @@
"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.",

View File

@@ -1666,6 +1666,8 @@ export interface components {
birthYear?: number;
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
};
Person: {
/** Format: uuid */
@@ -1681,6 +1683,8 @@ export interface components {
birthYear?: number;
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
familyMember: boolean;
sourceRef?: string;
provisional: boolean;
@@ -2285,6 +2289,8 @@ export interface components {
birthYear?: number;
/** Format: int32 */
deathYear?: number;
/** Format: int32 */
generation?: number | null;
familyMember: boolean;
};
InferredRelationshipDTO: {

View File

@@ -1,7 +1,13 @@
<script lang="ts">
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
to a single $derived.by computation; never mutated after layout. */
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
import {
buildLayout,
NODE_W,
NODE_H,
ROW_GAP,
type Layout
} from '$lib/person/genealogy/layout/buildLayout';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -12,274 +18,76 @@ interface Props {
selectedId: string | null;
zoom: number;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
* back to a `window.matchMedia('(min-width: 768px)')` detection so the
* gutter only appears on md+ viewports. Tests pass an explicit boolean
* to avoid depending on the vitest-browser iframe viewport.
*/
showGutter?: boolean;
}
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;
};
let { nodes, edges, selectedId, zoom, onSelect, showGutter }: Props = $props();
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
// Stammbaum gutter (#689). 100 px column on the left of the canvas on md+
// viewports, carrying the G{n} label per generation row. Hidden entirely on
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
// too costly on a 320 px screen).
const GUTTER_WIDTH_DESKTOP = 100;
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
// Seed synchronously so the first paint already has the right gutter state —
// otherwise the test (and a brief flash on real CSR mount) would see the
// pre-effect false. SSR has no window; the gutter stays hidden until hydrate.
let isMdOrUp = $state(
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(GUTTER_MEDIA_QUERY).matches
: false
);
$effect(() => {
if (showGutter !== undefined) return;
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
const gutterVisible = $derived(showGutter ?? isMdOrUp);
const gutterWidth = $derived(gutterVisible ? GUTTER_WIDTH_DESKTOP : 0);
type GutterRow = { rank: number; y: number; label: number | null };
const gutterRows = $derived.by<GutterRow[]>(() => {
if (gutterWidth === 0) return [];
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
const rows: GutterRow[] = [];
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
for (const rank of sortedRanks) {
const ids = layout.generations.get(rank)!;
const firstPos = layout.positions.get(ids[0]);
if (!firstPos) continue;
let label: number | null = null;
for (const id of ids) {
const g = byId.get(id)?.generation;
if (g != null) {
label = g;
break;
}
}
rows.push({ rank, y: firstPos.y, label });
}
return rows;
});
const viewBox = $derived.by(() => {
const w = layout.viewW / zoom;
const totalW = layout.viewW + gutterWidth;
const w = totalW / zoom;
const h = layout.viewH / zoom;
const cx = layout.viewX + layout.viewW / 2;
const cx = layout.viewX - gutterWidth + totalW / 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;
@@ -312,22 +120,25 @@ type ParentLinks = {
};
const parentLinks = $derived.by<ParentLinks>(() => {
const spousePairs = new Set<string>();
const spousePairs = new SvelteSet<string>();
for (const e of spouseEdges) {
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
}
const childToParents = new Map<string, string[]>();
const childToParents = new SvelteMap<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 sharedMap = new SvelteMap<
string,
{ parentA: string; parentB: string; childIds: string[] }
>();
const single: ParentLinks['single'] = [];
for (const [childId, parents] of childToParents) {
const consumed = new Set<string>();
const consumed = new SvelteSet<string>();
for (let i = 0; i < parents.length; i++) {
if (consumed.has(parents[i])) continue;
for (let j = i + 1; j < parents.length; j++) {
@@ -369,6 +180,44 @@ const parentLinks = $derived.by<ParentLinks>(() => {
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
transparent / var(--c-gutter-stripe). aria-hidden because they carry
no meaning; the row's generation is announced by the label group below. -->
{#each gutterRows as row, i (`stripe-${row.rank}`)}
<rect
aria-hidden="true"
x={layout.viewX - gutterWidth}
y={row.y - ROW_GAP / 2}
width={layout.viewW + gutterWidth}
height={NODE_H + ROW_GAP}
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
/>
{/each}
<!-- Gutter labels (#689) — `G{node.generation}` per occupied row at the
un-shifted source-truth value. Wrapped in <g role="text"> so screen
readers announce "Generation three" instead of "G three". -->
{#each gutterRows as row (`label-${row.rank}`)}
{#if row.label != null}
<g role="text" aria-label={`Generation ${row.label}`}>
<text
x={layout.viewX - gutterWidth + 12}
y={row.y + NODE_H / 2}
text-anchor="start"
dominant-baseline="middle"
font-family="var(--font-sans)"
font-size="12"
font-weight="700"
letter-spacing="0.08em"
fill="var(--c-ink-2)"
style:text-transform="uppercase"
>
G{row.label}
</text>
</g>
{/if}
{/each}
<!-- 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)}

View File

@@ -648,3 +648,58 @@ describe('StammbaumTree node rendering branches', () => {
expect(accentRects.length).toBe(1);
});
});
describe('StammbaumTree generation gutter (#689)', () => {
it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
// showGutter overrides the matchMedia detection so the test never
// depends on the vitest-browser iframe viewport width.
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
{ id: ID_B, displayName: 'Herbert', familyMember: true, generation: 3 }
],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {},
showGutter: true
});
const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) =>
g.getAttribute('aria-label')
);
expect(labels).toContain('Generation 2');
expect(labels).toContain('Generation 3');
});
it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {},
showGutter: true
});
const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find(
(g) => g.getAttribute('aria-label') === 'Generation 3'
);
expect(g3).toBeDefined();
expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
});
it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {},
showGutter: false
});
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
expect(labelGroups).toHaveLength(0);
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { buildLayout, NODE_H, ROW_GAP } from './buildLayout';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const PARENT = '00000000-0000-0000-0000-000000000001';
const CHILD = '00000000-0000-0000-0000-000000000002';
const SPOUSE_A = '00000000-0000-0000-0000-000000000003';
const SPOUSE_B = '00000000-0000-0000-0000-000000000004';
const NEGATIVE_A = '00000000-0000-0000-0000-000000000005';
const NEGATIVE_B = '00000000-0000-0000-0000-000000000006';
const NEGATIVE_C = '00000000-0000-0000-0000-000000000007';
function node(id: string, displayName: string, generation: number | null = null): PersonNodeDTO {
return generation == null
? { id, displayName, familyMember: true }
: { id, displayName, familyMember: true, generation };
}
function parentEdge(parentId: string, childId: string, id = parentId + childId): RelationshipDTO {
return {
id,
personId: parentId,
relatedPersonId: childId,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
};
}
function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
return {
id,
personId: a,
relatedPersonId: b,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF'
};
}
function yOf(layout: ReturnType<typeof buildLayout>, id: string): number {
const p = layout.positions.get(id);
if (!p) throw new Error(`No position for ${id}`);
return p.y;
}
describe('buildLayout — generation seeding (#689)', () => {
it('Herbert Cram regression: two parented G=3 spouses share the same row', () => {
// Both Herbert (G 3) and Clara (G 3) are parented children of their respective
// G 2 ancestors. They are spouses. Before #689 the iterative longest-path put
// Herbert one row deeper than Clara via the spouse-pulldown of his loose parent.
// With imported generation as a strict seed both render at the same y.
const layout = buildLayout(
[node(SPOUSE_A, 'Herbert', 3), node(SPOUSE_B, 'Clara', 3)],
[spouseEdge(SPOUSE_A, SPOUSE_B)]
);
expect(yOf(layout, SPOUSE_A)).toBe(yOf(layout, SPOUSE_B));
});
it('strict-seed override: imported generation pins rank even when parent edges imply deeper', () => {
// PARENT has no explicit generation → falls back to 0. CHILD is parented under
// PARENT but has imported generation = 3. The seeded rank wins; the heuristic
// must not push CHILD to rank 1.
const layout = buildLayout(
[node(PARENT, 'Parent'), node(CHILD, 'Child', 3)],
[parentEdge(PARENT, CHILD)]
);
expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
});
it('fallback inherits seeded parent rank: G 2 parent → null-gen child lands at rank 3', () => {
// CHILD has no imported generation. PARENT has generation = 2. The fallback
// reads PARENT's rank from the unified rank map (2) and computes 2 + 1 = 3.
const layout = buildLayout(
[node(PARENT, 'Parent', 2), node(CHILD, 'Child')],
[parentEdge(PARENT, CHILD)]
);
expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
});
it('normalise is a no-op when all ranks are non-negative', () => {
// Seeded ranks [3, 4, 5] → y must reflect [3, 4, 5] without any shift.
const G3 = '00000000-0000-0000-0000-000000000031';
const G4 = '00000000-0000-0000-0000-000000000032';
const G5 = '00000000-0000-0000-0000-000000000033';
const layout = buildLayout(
[node(G3, 'three', 3), node(G4, 'four', 4), node(G5, 'five', 5)],
[]
);
expect(yOf(layout, G3)).toBe(3 * (NODE_H + ROW_GAP));
expect(yOf(layout, G4)).toBe(4 * (NODE_H + ROW_GAP));
expect(yOf(layout, G5)).toBe(5 * (NODE_H + ROW_GAP));
});
it('normalise shifts negative seeds so min rank becomes 0', () => {
// Seeded ranks [-1, 0, 1] → after shift they render at [0, 1, 2] y-rows.
const layout = buildLayout(
[node(NEGATIVE_A, 'minus-one', -1), node(NEGATIVE_B, 'zero', 0), node(NEGATIVE_C, 'one', 1)],
[]
);
expect(yOf(layout, NEGATIVE_A)).toBe(0);
expect(yOf(layout, NEGATIVE_B)).toBe(1 * (NODE_H + ROW_GAP));
expect(yOf(layout, NEGATIVE_C)).toBe(2 * (NODE_H + ROW_GAP));
});
});

View File

@@ -0,0 +1,279 @@
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
export const NODE_W = 160;
export const NODE_H = 56;
export const COL_GAP = 40;
export const ROW_GAP = 80;
export const VIEWBOX_PAD = 80;
export const MIN_VIEWBOX_W = 1200;
export const MIN_VIEWBOX_H = 800;
export type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
viewX: number;
viewY: number;
viewW: number;
viewH: number;
};
export 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;
}
}
// Two-stage rank assignment (#689):
//
// 1. Seed: every node with imported generation is locked at that rank.
// The fallback heuristic never moves a locked rank, and spouse-pulldown
// never pulls a locked rank.
// 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank)
// + 1, reading parent rank from the same unified map so an unseeded
// child of a seeded G 2 parent correctly inherits rank 3. Spouse-
// pulldown ties unseeded spouses to their deeper partner.
// 3. Normalise: if any seeded rank is negative (a future G 1 ancestor),
// shift the entire map so min(rank) == 0. No-op fast path covers
// today's data.
const rank = new Map<string, number>();
const locked = new Set<string>();
for (const n of allNodes) {
if (n.generation != null) {
rank.set(n.id, n.generation);
locked.add(n.id);
} else {
rank.set(n.id, 0);
}
}
const maxIters = allNodes.length + 4;
for (let it = 0; it < maxIters; it++) {
let changed = false;
for (const n of allNodes) {
if (locked.has(n.id)) continue;
const parents = childToParents.get(n.id) ?? [];
if (parents.length === 0) continue;
let maxParentRank = -Infinity;
for (const pid of parents) {
maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0);
}
const newRank = maxParentRank + 1;
if ((rank.get(n.id) ?? 0) < newRank) {
rank.set(n.id, newRank);
changed = true;
}
}
for (const [a, b] of spousePairs) {
const ra = rank.get(a) ?? 0;
const rb = rank.get(b) ?? 0;
const m = Math.max(ra, rb);
if (!locked.has(a) && ra < m) {
rank.set(a, m);
changed = true;
}
if (!locked.has(b) && rb < m) {
rank.set(b, m);
changed = true;
}
}
if (!changed) break;
}
let minRank = Infinity;
for (const r of rank.values()) minRank = Math.min(minRank, r);
if (minRank < 0) {
const shift = -minRank;
for (const [id, r] of rank) rank.set(id, r + shift);
}
// Group by rank, then sort within rank by display name.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = rank.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]);
}

View File

@@ -11,6 +11,7 @@ export type PersonFormData = {
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
generation?: number | null;
notes?: string | null;
};

View File

@@ -160,6 +160,12 @@
with axe (tracked in #480) before tweaking the palette. */
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
--timeline-bar-outside: var(--c-line);
/* Stammbaum gutter stripe (issue #689) — decorative full-row underlay
alternating with transparent. Mint-tinted on canvas to mark generation
rows without competing with node fills. 8% on light surface ≈ #ECF6F4
(~1.04:1 vs canvas — decorative carve-out). */
--c-gutter-stripe: rgba(161, 220, 216, 0.08);
}
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
@@ -236,6 +242,10 @@
clears WCAG 1.4.11 non-text contrast for large UI elements. */
--timeline-bar-idle: #3a6e8c;
--timeline-bar-outside: #1a2735;
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
visibility parity with the 8% light-mode token. Decorative carve-out. */
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
}
}
@@ -308,6 +318,9 @@
clears WCAG 1.4.11 non-text contrast for large UI elements. */
--timeline-bar-idle: #3a6e8c;
--timeline-bar-outside: #1a2735;
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */

View File

@@ -51,6 +51,12 @@ export const actions = {
const deathYearStr = formData.get('deathYear')?.toString().trim();
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
// Must NOT use the conditional-spread idiom for generation: G 0 is a
// valid family-tree-root value. The key always travels in the body so
// an explicit clear (empty option) reaches the backend as null.
const generationRaw = formData.get('generation');
const generation =
generationRaw == null || generationRaw.toString() === '' ? null : Number(generationRaw);
const validationKey = validatePersonFields(personType, firstName, lastName);
if (validationKey) {
@@ -68,7 +74,8 @@ export const actions = {
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {})
...(deathYear ? { deathYear } : {}),
generation
}
});

View File

@@ -16,6 +16,12 @@ let selectedType = $state<PersonType>(
)
);
// Match the selectedType initialiser pattern: untrack so a subsequent prop
// update (e.g. load() rerun) does not reset the user's in-progress edit.
let generationStr = $state(
untrack(() => (person.generation == null ? '' : String(person.generation)))
);
const isPerson = $derived(selectedType === 'PERSON');
const lastNameLabel = $derived(
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
@@ -108,6 +114,28 @@ const inputCls =
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="generation" class={labelCls}>{m.person_label_generation()}</label>
<select
id="generation"
name="generation"
bind:value={generationStr}
class="block min-h-[44px] w-full rounded border border-line bg-surface px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-describedby="generation-hint"
>
<option value="">{m.person_option_generation_unset()}</option>
<option value="0">G 0</option>
<option value="1">G 1</option>
<option value="2">G 2</option>
<option value="3">G 3</option>
<option value="4">G 4</option>
<option value="5">G 5</option>
<option value="6">G 6</option>
</select>
<p id="generation-hint" class="mt-1 font-sans text-xs text-ink-3">
{m.person_hint_generation()}
</p>
</div>
{/if}
<div class="md:col-span-2">

View File

@@ -113,4 +113,48 @@ describe('PersonEditForm', () => {
expect(alias.value).toBe('');
expect(birthYear.value).toBe('');
});
// ─── generation dropdown (#689) ─────────────────────────────────────────────
it('renders the generation select with G 0…G 6 options when personType is PERSON', async () => {
render(PersonEditForm, { props: { person: personPersonal } });
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
const labels = Array.from(select.options).map((o) => o.label.trim());
expect(labels).toEqual(
expect.arrayContaining(['G 0', 'G 1', 'G 2', 'G 3', 'G 4', 'G 5', 'G 6'])
);
});
it('hides the generation select for INSTITUTION', async () => {
render(PersonEditForm, { props: { person: personInstitution } });
await expect.element(page.getByLabelText(/^generation$/i)).not.toBeInTheDocument();
});
it('hydrates the generation select from person.generation', async () => {
render(PersonEditForm, {
props: {
person: { ...personPersonal, generation: 3 } as typeof personPersonal & {
generation: number;
}
}
});
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
expect(select.value).toBe('3');
});
it('hydrates the generation select to "" when person.generation is null', async () => {
render(PersonEditForm, {
props: {
person: { ...personPersonal, generation: null } as typeof personPersonal & {
generation: number | null;
}
}
});
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
expect(select.value).toBe('');
});
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server';
import { actions } from './+page.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
beforeEach(() => vi.clearAllMocks());
function makeFormData(overrides: Partial<Record<string, string>> = {}): {
request: Request;
redirectThrown: () => unknown;
} {
const fd = new FormData();
fd.set('personType', 'PERSON');
fd.set('firstName', 'Hans');
fd.set('lastName', 'Müller');
for (const [k, v] of Object.entries(overrides)) {
if (v == null) fd.delete(k);
else fd.set(k, v);
}
return {
request: new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd }),
redirectThrown: () => {}
};
}
describe('persons/[id]/edit update action — generation (#689)', () => {
it('always includes generation in the PUT body — even when value is 0', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const { request } = makeFormData({ generation: '0' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(put).toHaveBeenCalledTimes(1);
const body = put.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', 0);
});
it('sends generation: null when the dropdown is cleared (empty option)', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const { request } = makeFormData({ generation: '' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(put).toHaveBeenCalledTimes(1);
const body = put.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', null);
});
it('sends generation: 3 when the dropdown carries G 3', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const { request } = makeFormData({ generation: '3' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(put).toHaveBeenCalledTimes(1);
const body = put.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', 3);
});
});

View File

@@ -26,6 +26,12 @@ export const actions = {
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
const notes = formData.get('notes')?.toString().trim() || undefined;
// Must NOT use the conditional-spread idiom for generation: G 0 is a
// valid family-tree-root value. Always travels in the body so an
// explicit clear (empty option) reaches the backend as null.
const generationRaw = formData.get('generation');
const generation =
generationRaw == null || generationRaw.toString() === '' ? null : Number(generationRaw);
const validationKey = validatePersonFields(personType, firstName, lastName);
if (validationKey) {
@@ -52,7 +58,8 @@ export const actions = {
...(alias ? { alias } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
...(notes ? { notes } : {})
...(notes ? { notes } : {}),
generation
}
});

View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server';
import { actions } from './+page.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
beforeEach(() => vi.clearAllMocks());
function buildRequest(overrides: Partial<Record<string, string>> = {}): Request {
const fd = new FormData();
fd.set('personType', 'PERSON');
fd.set('firstName', 'Hans');
fd.set('lastName', 'Müller');
for (const [k, v] of Object.entries(overrides)) {
if (v == null) fd.delete(k);
else fd.set(k, v);
}
return new Request('http://localhost/persons/new', { method: 'POST', body: fd });
}
describe('persons/new create action — generation (#689)', () => {
it('always includes generation in the POST body — even when value is 0', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = buildRequest({ generation: '0' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.default({ request, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(post).toHaveBeenCalledTimes(1);
const body = post.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', 0);
});
it('sends generation: null when the dropdown is left unset', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = buildRequest({ generation: '' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.default({ request, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(post).toHaveBeenCalledTimes(1);
const body = post.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', null);
});
});