From 7e8b90c8ee7f3bfa0d0e326eb4ee4784e4ab0b34 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:13:26 +0200 Subject: [PATCH] feat(stammbaum): add familyForest.pickStructuralOwner (#724) Structural-owner rule for couples: earlier birth year wins, missing year sorts last, ties break on stable id. The single definition reused by the cross-link, cycle and intra-family paths. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/familyForest.test.ts | 23 +++++++++++++ .../person/genealogy/layout/familyForest.ts | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 frontend/src/lib/person/genealogy/layout/familyForest.test.ts create mode 100644 frontend/src/lib/person/genealogy/layout/familyForest.ts diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts new file mode 100644 index 00000000..33ea1c51 --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { pickStructuralOwner } from './familyForest'; + +type Person = { id: string; birthYear?: number }; + +describe('pickStructuralOwner', () => { + const p = (id: string, birthYear?: number): Person => ({ id, birthYear }); + + it('picks the earlier-born spouse as structural owner', () => { + expect(pickStructuralOwner(p('a', 1900), p('b', 1920))).toBe('a'); + expect(pickStructuralOwner(p('a', 1920), p('b', 1900))).toBe('b'); + }); + + it('sorts a missing birthYear last (the dated spouse owns)', () => { + expect(pickStructuralOwner(p('a'), p('b', 1900))).toBe('b'); + expect(pickStructuralOwner(p('a', 1900), p('b'))).toBe('a'); + }); + + it('breaks ties on stable id when birth years match or are both missing', () => { + expect(pickStructuralOwner(p('zzz', 1900), p('aaa', 1900))).toBe('aaa'); + expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa'); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts new file mode 100644 index 00000000..6feb0a5e --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -0,0 +1,34 @@ +// Domain-aware construction of the genealogy "family forest" (#724). +// +// This module owns every piece of genealogy knowledge the layout needs — +// spouse runs, birth-year ordering, structural-owner selection, intra-family +// marriage resolution and cross-links — and flattens it into the abstract +// { id, width, children } nodes that the domain-agnostic tidyTree.ts packs. +// buildLayout.ts orchestrates the two and maps run positions back to persons. + +import type { components } from '$lib/generated/api'; + +type PersonNodeDTO = components['schemas']['PersonNodeDTO']; + +/** + * Choose the structural owner of a couple: the spouse who keeps the bloodline + * (hierarchy) position. Earlier birth year wins; a missing birth year sorts + * last; ties break on the stable id. Shared by the cycle, cross-link and + * intra-family paths so the rule is defined exactly once. + */ +export function pickStructuralOwner( + a: { id: string; birthYear?: number | null }, + b: { id: string; birthYear?: number | null } +): string { + const ra = ownerRank(a); + const rb = ownerRank(b); + if (ra !== rb) return ra < rb ? a.id : b.id; + return a.id <= b.id ? a.id : b.id; +} + +// Lower is "more owning". Missing birth year → +Infinity (sorts last). +function ownerRank(p: { birthYear?: number | null }): number { + return p.birthYear == null ? Number.POSITIVE_INFINITY : p.birthYear; +} + +export type { PersonNodeDTO };