From add619d81da5b2f80db3339a729c316ff665b54d Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:21:17 +0200 Subject: [PATCH] feat(stammbaum): order siblings/branches by birthYear NULLS LAST, displayName, id (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new ordering coverage: roots and every unit's children sort by birthYear ASC (undated last), then displayName, then stable id — so horizontal x never depends on Map iteration order. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/familyForest.test.ts | 31 +++++++++++++++++++ .../person/genealogy/layout/familyForest.ts | 13 ++++++++ 2 files changed, 44 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts index ef086bf4..2a1ace57 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -108,3 +108,34 @@ describe('buildFamilyForest — loose-spouse absorption', () => { expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']); }); }); + +describe('buildFamilyForest — sibling/branch ordering', () => { + it('orders children by birthYear ASC, NULLS LAST, then displayName, then id', () => { + // Provided out of order; the comparator must reorder to 1910, 1920, then + // the two undated by displayName/id (NULLS LAST). + const undatedB: PersonNodeDTO = { id: 'u-b', displayName: 'Zoe', familyMember: true }; + const undatedA: PersonNodeDTO = { id: 'u-a', displayName: 'Anna', familyMember: true }; + const forest = buildFamilyForest( + [ + person('P', { birthYear: 1880 }), + person('c1920', { birthYear: 1920 }), + undatedB, + person('c1910', { birthYear: 1910 }), + undatedA + ], + [parent('P', 'c1920'), parent('P', 'u-b'), parent('P', 'c1910'), parent('P', 'u-a')] + ); + + const p = findUnit(forest.roots, 'P')!; + // 1910, 1920 (dated ASC), then undated by displayName (Anna < Zoe). + expect(p.children.map((u) => u.id)).toEqual(['c1910', 'c1920', 'u-a', 'u-b']); + }); + + it('orders roots by the same rule', () => { + const forest = buildFamilyForest( + [person('late', { birthYear: 1900 }), person('early', { birthYear: 1850 })], + [] + ); + expect(forest.roots.map((r) => r.id)).toEqual(['early', 'late']); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts index 1fc2be4e..0663ebd1 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -174,6 +174,19 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO else unitObj.get(hp)!.children.push(unitObj.get(p)!); } + // Sibling/branch order: birthYear ASC NULLS LAST → displayName → id. Applied + // to roots and to every unit's children so x never depends on Map order. + const branchOrder = (u: Unit, v: Unit) => { + const a = byId.get(u.id)!; + const b = byId.get(v.id)!; + const ya = a.birthYear ?? Number.POSITIVE_INFINITY; + const yb = b.birthYear ?? Number.POSITIVE_INFINITY; + if (ya !== yb) return ya - yb; + return a.displayName.localeCompare(b.displayName) || (u.id < v.id ? -1 : 1); + }; + roots.sort(branchOrder); + for (const u of unitObj.values()) u.children.sort(branchOrder); + // --- Cross-links: displaced parent → absorbed-spouse edges. --- const crossLinks: CrossLink[] = []; for (const k of absorbedInto.keys()) {