Compare commits

...

10 Commits

Author SHA1 Message Date
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
32 changed files with 1027 additions and 44 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

@@ -11,6 +11,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 +27,15 @@ 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 static final int GENERATION_MIN = 0;
private static final int GENERATION_MAX = 10;
private final PersonService personService;
public int load(File artifact) {
@@ -49,11 +60,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 {@value #GENERATION_MIN}..{@value #GENERATION_MAX}
* (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 < GENERATION_MIN || parsed > GENERATION_MAX) {
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

@@ -79,12 +79,32 @@ 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 in
* {@value #GENERATION_MIN}..{@value #GENERATION_MAX}; 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 < GENERATION_MIN || raw > GENERATION_MAX) {
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
return null;
}
return raw;
}
private static final int GENERATION_MIN = 0;
private static final int GENERATION_MAX = 10;
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
int created = 0;
for (JsonNode node : relationships) {

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

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

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

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

@@ -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">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
import { buildLayout, NODE_W, NODE_H, type Layout } from '$lib/person/genealogy/layout/buildLayout';
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'];
@@ -17,10 +23,52 @@ interface Props {
let { nodes, edges, selectedId, zoom, onSelect }: 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)';
let isMdOrUp = $state(false);
$effect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
isMdOrUp = mq.matches;
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
const gutterWidth = $derived(isMdOrUp ? 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}`;
});
@@ -117,6 +165,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,69 @@ 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 () => {
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: () => {}
});
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: () => {}
});
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 matchMedia (min-width: 768px) is false', async () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false
})) as unknown as typeof window.matchMedia;
try {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
expect(labelGroups).toHaveLength(0);
} finally {
window.matchMedia = originalMatchMedia;
}
});
});

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

@@ -38,49 +38,71 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
}
}
// Iterative longest-path generation assignment.
// Two-stage rank assignment (#689):
//
// 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);
// 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 maxParentGen = -1;
let maxParentRank = -Infinity;
for (const pid of parents) {
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0);
}
const newGen = maxParentGen + 1;
if ((generation.get(n.id) ?? 0) < newGen) {
generation.set(n.id, newGen);
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 m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
if ((generation.get(a) ?? 0) < m) {
generation.set(a, m);
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 ((generation.get(b) ?? 0) < m) {
generation.set(b, m);
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 generation, then sort within generation by display name.
// Group by rank, then sort within rank by display name.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = generation.get(n.id) ?? 0;
const g = rank.get(n.id) ?? 0;
if (!generations.has(g)) generations.set(g, []);
generations.get(g)!.push(n.id);
}

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);
});
});