Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ updates:
interval: weekly
day: monday
open-pull-requests-limit: 10
ignore:
- dependency-name: typescript
update-types:
- version-update:semver-major
groups:
dev-tooling:
patterns:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
- run: npm test

e2e-smoke:
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
needs: build
permissions:
Expand Down
68 changes: 66 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,68 @@ Check items off here; when a slice ships, also update the roadmap entry and add
- [x] **U18l — Referrer buckets for human visits** (google / AI assistants / direct — header
only, no IP).

## DNA analysis (roadmap K9/K10)

## New from 2026-07-06 screenshot sweep (post-K10/K11/K12/K13 catch-up)

- [ ] **V5 — Triage errors the new Errors panel caught (production crashes).**
`canonicalizeLegacyPublicUrl is not defined` + `isAuthCallbackUrl is not defined`
(U16-refactor ReferenceErrors on public routes), React conditional-hook crash (10 boundary
hits on `/tree/gether-gamby*`), "child already linked" thrown instead of inline validation.
Plus: per-signature resolved flag, route↔signature cross-links.
- [ ] **O2 — BUG: data-quality year extraction false positives.** "lifespan 17–1980 (1963
years)", "death (19) before birth (27)" — day numbers/partial dates parsed as years. Clamp
to plausible years, treat missing-year as unknown, skip checks on unknown; add dismiss/
convert-to-Discrepancy actions (O remainder).
- [ ] **K14 — Lineage pill: raw UUID as MRCA (bug) + relationship-first wording.** "2 hops ·
MRCA 5b652651-…" → resolve all path names (never print an id); lead with "Grandchild — via
Niels Gether Nielsen" instead of hop counts.
- [ ] **K15 — DNA panel polish round 2.** Collapse n/a stat rows on family kits + surface K6
"Compare raw kits" CTA; K9-hypothesis fallback labels for unnamed Leeds clusters; cluster ↔
K3 cross-links; in-place refresh after "Set kit owner to selected person".
- [ ] **Y3 — Relationship-edge direction hygiene.** Canonical `asParentChildEdge()` accessor;
migrate the five orientation case-switches (lineage label, child-map, crawl relations,
relationship calculator, family-kit labeler); fixtures for both storage conventions.
- [ ] **K16 — BUG: Y-DNA field shown for females.** DNATab renders a Y-DNA haplogroup input for
Pernille (gender F) — women have no Y chromosome. Thread `person.gender` in, hide Y-DNA (+ its
migration route) for F; mtDNA/Mitotree stay for all; placeholders never render as values.
- [ ] **K17 — NEW: haplogroup line propagation + contradiction check.** Inherit mtDNA down maternal
lines / Y-DNA down paternal lines via the relationship graph (pre-fill "inherited
(unconfirmed)"); flag same-line haplogroup conflicts as O data-quality errors; optional
pedigree tint overlay. `lib/haplogroupInheritance.ts`.
- [ ] **L8 — Timeline view layout redesign.** Cards positioned by `(index % 8)*72px` (arbitrary
cascade, overlaps, no axis tie). Greedy lane packing + leader lines + decade ticks; consider
lifespan bars. Pure math in `lib/timelineLayout.ts`.

## Dashboard / books / media (screenshot sweep 2026-07-06 — roadmap Z/M13/O3/Q1)

- [ ] **Z1 — Nameless rows in "What's New" / "Most Wanted".** Blank names + bare "Hansson" from K3
DNA placeholders; universal display-name fallback + placeholder badge; placeholder creation
always yields a usable name ("Unknown (DNA match, 45 cM)").
- [ ] **Z2 — "This Month" unsorted + no birthday/anniversary distinction.** Jul 20 → 18 → 24; sort
by day; label deceased people's dates as anniversaries, not birthdays.
- [ ] **Z3 — Share button emits legacy `?tree=` URLs** (`TreeLandingPage.tsx` L71) — use canonical
U16 slug URLs (`buildTreeUrl`); grep for other legacy URL builders.
- [ ] **Z4 — Stat redundancy:** Oldest Birth Record shown twice on one screen (Key Benchmarks +
Demographic Pulse); merge/differentiate cards.
- [ ] **Z5 — "Most Wanted" DATE/MEDIA chips → deep-link** to the person's Vital/Media tab; carry
person filter into Research panel.
- [ ] **O3 — Fuzzy near-duplicate detection.** "Eva Hansson" vs "Eva Hansen" added same day; extend
O duplicate check with name-variant matching (`dnaPersonNameVariants` + Nordic normalization
from R); warn + side-by-side compare; check at K3 placeholder creation ("similar person
exists — link instead?"). Merge-persons action may be its own slice.
- [ ] **M13 — Saved-books list hygiene.** Two identical "Christensdatter Family History" rows →
version-group same-title books (surface M4 history) or warn on duplicate title; scope/style/
language badges per row; delete confirmation.
- [ ] **Q1 — External media-link UX.** Broken-image frames for hotlink-protected archive URLs →
link-card fallback on img error; "New Media Link" placeholder caption persists as title
(`PersonProfile.tsx:1026`) → editable/derived title, never persist placeholder; recognize
Mediestream/Arkivalieronline URLs as typed source cards + "attach as Source citation".

## DNA analysis (roadmap K1/K9/K10)

- [x] **K1 — Segment triangulation / Leeds clustering.** Strict ICW join (50%), parental-side cluster
split when paths conflict, four-grandparent Leeds labels + K2 MRCA fallback; admin segment
clusters + painter. `lib/dnaClustering.ts`, `lib/dnaParentalHints.ts`, `lib/dnaClusterLabels.ts`,
`wiki/sources/dna-triangulation.md`.
- [x] **K10 — BUG: family kits are 1-hop only.** `list_family_autosomal_kits` joins only direct
parent↔child edges, so a tested grandmother (Helle Andersen, 2 hops from Pernille), sibling,
or cousin never appears on the DNA panel. Enumerate all in-tree autosomal kits and label each
Expand All @@ -62,6 +122,10 @@ Check items off here; when a slice ships, also update the roadmap entry and add
likely connects via ancestor couple X" suggestion with research to-dos. Pedigree UI: amber
coverage-gap halo (inverse of the DNA badges) + hypothesis mode highlighting candidate
branches. `lib/dnaUncoveredBranches.ts`, `uncovered_branch` in `dnaMatchPlacement.ts`.
- [x] **K13 — BUG: lineage summary names the DNA match as MRCA.** Long paths with missing or
mis-oriented edge labels fell back to the counterpart person (e.g. "11 hops · MRCA Kenneth
Russell Hansen"). `pickLineageMrcaPersonId` now uses path topology + `computeRelationship`
and never treats the match as MRCA on multi-hop paths (`lib/dnaLineagePathLabel.ts`).

## DNA panel (screenshot review 2026-07-05 — roadmap K8)

Expand Down
63 changes: 47 additions & 16 deletions components/AdminDnaPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ import {
inferPathGrandparentSlot,
inferPathParentalSide,
resolveGrandparentSlots,
splitClustersByParentalSide,
type GrandparentSlot,
type ParentalSideHint,
} from '../lib/dnaParentalHints';
import { suggestUnknownMatchPlacements } from '../lib/dnaMatchPlacement';
import { sharedMatchToUnlinkedRecord } from '../lib/dnaUnlinkedMatchAdapter';
import { buildClusterLabelByIndex, formatClusterHeading } from '../lib/dnaClusterLabels';
import { buildLeedsClusterLabels, formatClusterHeading } from '../lib/dnaClusterLabels';
import {
createDnaMatchPlaceholderPerson,
dismissUnlinkedDnaMatchForFocus,
Expand Down Expand Up @@ -164,7 +165,7 @@ const AdminDnaPanel: React.FC<AdminDnaPanelProps> = ({

const minIcwOverlapFraction = strictIcw ? 0.5 : 0;

const clusterGroups = useMemo(() => {
const rawClusterGroups = useMemo(() => {
if (!segmentBackedMatches.length) return [];
return clusterSharedSegments(
segmentBackedMatches.map((match) => ({
Expand All @@ -175,11 +176,6 @@ const AdminDnaPanel: React.FC<AdminDnaPanelProps> = ({
);
}, [segmentBackedMatches, segmentsByMatchId, minClusterCm, minIcwOverlapFraction]);

const clusterSummaries = useMemo(
() => clusterGroups.map((group) => summarizeClusterIcw(group, segmentsByMatchId, minIcwOverlapFraction)),
[clusterGroups, segmentsByMatchId, minIcwOverlapFraction]
);

interface MatchLineageHints {
parentalSide: ParentalSideHint;
grandparentSlot: GrandparentSlot | null;
Expand All @@ -201,6 +197,19 @@ const AdminDnaPanel: React.FC<AdminDnaPanelProps> = ({
return hints;
}, [matches, resolutionByMatchId, selectedPersonId, relationships, grandparentSlots]);

const clusterGroups = useMemo(() => {
const sideByMatchId = new Map<string, ParentalSideHint>();
matchLineageHints.forEach((hints, matchId) => {
sideByMatchId.set(matchId, hints.parentalSide);
});
return splitClustersByParentalSide(rawClusterGroups, sideByMatchId);
}, [rawClusterGroups, matchLineageHints]);

const clusterSummaries = useMemo(
() => clusterGroups.map((group) => summarizeClusterIcw(group, segmentsByMatchId, minIcwOverlapFraction)),
[clusterGroups, segmentsByMatchId, minIcwOverlapFraction]
);

const clusteredMatchIds = useMemo(() => new Set(clusterGroups.flat()), [clusterGroups]);

const overlapSingletons = useMemo(
Expand Down Expand Up @@ -271,10 +280,18 @@ const AdminDnaPanel: React.FC<AdminDnaPanelProps> = ({
});
}, [selectedPersonId, matches.length, mrcaMatchInputs, relationships, resolvePersonName, clusterGroups]);

const clusterLabelsByIndex = useMemo(
() => buildClusterLabelByIndex(clusterGroups.length, mrcaCandidates),
[clusterGroups.length, mrcaCandidates]
);
const clusterLabelsByIndex = useMemo(() => {
const slotByMatchId = new Map<string, GrandparentSlot | null>();
matchLineageHints.forEach((hints, matchId) => {
slotByMatchId.set(matchId, hints.grandparentSlot);
});
return buildLeedsClusterLabels(
clusterGroups,
grandparentSlots,
slotByMatchId,
mrcaCandidates
);
}, [clusterGroups, grandparentSlots, matchLineageHints, mrcaCandidates]);

const linkedMatchSegmentInputs = useMemo(
() =>
Expand Down Expand Up @@ -803,13 +820,21 @@ const AdminDnaPanel: React.FC<AdminDnaPanelProps> = ({
const focusName = personNameById.get(selectedPersonId);
if (focusName) pathNames.set(selectedPersonId, focusName);
}
const lineageMrcaOptions =
selectedPersonId && match.counterpartPersonId
? {
focusPersonId: selectedPersonId,
counterpartPersonId: match.counterpartPersonId,
}
: undefined;
const lineageBreadcrumb =
match.pathFound && pathPersonIds.length
? buildDnaLineagePathBreadcrumb(
pathPersonIds,
pathRelationshipIds,
lineageRelationshipRows,
pathNames
pathNames,
lineageMrcaOptions
)
: [];
const pathSummary =
Expand All @@ -818,12 +843,18 @@ const AdminDnaPanel: React.FC<AdminDnaPanelProps> = ({
pathPersonIds,
pathRelationshipIds,
lineageRelationshipRows,
pathNames
pathNames,
lineageMrcaOptions
)
: 'No linked lineage path';
const mrcaPersonId =
match.pathFound && pathPersonIds.length
? pickLineageMrcaPersonId(pathPersonIds, pathRelationshipIds, lineageRelationshipRows)
? pickLineageMrcaPersonId(
pathPersonIds,
pathRelationshipIds,
lineageRelationshipRows,
lineageMrcaOptions
)
: null;
const viewInTreePersonId = mrcaPersonId || selectedPersonId;
return (
Expand Down Expand Up @@ -1218,8 +1249,8 @@ const AdminDnaPanel: React.FC<AdminDnaPanelProps> = ({
<p className="text-[11px] font-black text-slate-400 uppercase tracking-[0.3em]">Segment clusters</p>
<h3 className="text-xl font-serif font-bold text-slate-900 mt-1">Overlap groups (Leeds-style)</h3>
<p className="text-sm text-slate-500 mt-2 max-w-3xl">
Matches imported with shared-segment CSV rows are grouped when their owner-side segments overlap.
Enable strict ICW to require ~50% reciprocal overlap (reduces false clusters on unphased data).
Matches imported with shared-segment CSV rows are grouped when their owner-side segments overlap with strict ICW (50% reciprocal overlap by default).
Clusters that mix documented maternal and paternal paths are split automatically.
Parental / grandparent labels come from documented lineage paths when available.
</p>
</div>
Expand Down
84 changes: 58 additions & 26 deletions components/dna/SharedSegmentImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ interface SharedSegmentImportModalProps {
batchSummaries?: SharedSegmentBatchSummary[];
treePeople: SharedImportNameRow[];
autosomalTesters: SharedImportNameRow[];
/** When importing on a person profile, the CSV match links to this person (not the tester). */
profilePersonId?: string | null;
profileDisplayName?: string | null;
defaultOwnerPersonId?: string | null;
loadingPeople?: boolean;
lockOwnerPersonId?: string | null;
Expand All @@ -43,6 +46,8 @@ const SharedSegmentImportModal: React.FC<SharedSegmentImportModalProps> = ({
preview,
treePeople,
autosomalTesters,
profilePersonId,
profileDisplayName,
defaultOwnerPersonId,
lockOwnerPersonId,
loadingPeople = false,
Expand All @@ -59,39 +64,52 @@ const SharedSegmentImportModal: React.FC<SharedSegmentImportModalProps> = ({
const [counterpartPersonId, setCounterpartPersonId] = useState<string>('');
const [saveMarriedAlias, setSaveMarriedAlias] = useState(true);
const lockedOwnerId = lockOwnerPersonId || '';
const lockedCounterpartId = profilePersonId || '';

useEffect(() => {
if (!open) return;
const nextOwner = lockedOwnerId || suggestedOwnerId || defaultOwnerPersonId || '';
const nextOwner = lockedOwnerId || suggestedOwnerId || '';
setOwnerPersonId(nextOwner);
const counterpart = nextOwner
? suggestCounterpartPersonId(summary, nextOwner, treePeople)
: null;
const counterpart = lockedCounterpartId
? lockedCounterpartId
: nextOwner
? suggestCounterpartPersonId(summary, nextOwner, treePeople)
: null;
setCounterpartPersonId(counterpart || '');
setSaveMarriedAlias(true);
}, [open, suggestedOwnerId, defaultOwnerPersonId, summary, treePeople, lockedOwnerId]);
}, [
open,
suggestedOwnerId,
summary,
treePeople,
lockedOwnerId,
lockedCounterpartId,
]);

useEffect(() => {
if (!open || !ownerPersonId) return;
if (!open || lockedCounterpartId) return;
if (!ownerPersonId) return;
const counterpart = suggestCounterpartPersonId(summary, ownerPersonId, treePeople);
setCounterpartPersonId(counterpart || '');
}, [open, ownerPersonId, summary, treePeople]);
}, [open, ownerPersonId, summary, treePeople, lockedCounterpartId]);

if (!open) return null;

const csvOwnerName = ownerPersonId
? csvKitOwnerDisplayName(summary, ownerPersonId, treePeople)
? csvKitOwnerDisplayName(summary, ownerPersonId, autosomalTesters)
: summary.personName;
const offerMarriedAlias =
!!ownerPersonId &&
shouldOfferMarriedNameAlias(summary, ownerPersonId, treePeople, csvOwnerName);
shouldOfferMarriedNameAlias(summary, ownerPersonId, autosomalTesters, csvOwnerName);

const resolvedCounterpartId = lockedCounterpartId || counterpartPersonId;

const handleConfirm = () => {
const resolvedOwner = lockedOwnerId || ownerPersonId;
if (!resolvedOwner) return;
onConfirm({
ownerPersonId: resolvedOwner,
counterpartPersonId: counterpartPersonId || null,
counterpartPersonId: resolvedCounterpartId || null,
saveMarriedNameAlias: offerMarriedAlias && saveMarriedAlias,
marriedNameAlias: offerMarriedAlias && saveMarriedAlias ? csvOwnerName : undefined,
});
Expand Down Expand Up @@ -208,23 +226,37 @@ const SharedSegmentImportModal: React.FC<SharedSegmentImportModalProps> = ({

<label className="block space-y-2">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Counterpart (optional)
Match in tree
{lockedCounterpartId ? ' (this profile)' : ' (optional)'}
</span>
<select
value={counterpartPersonId}
onChange={(event) => setCounterpartPersonId(event.target.value)}
className="w-full rounded-xl border border-slate-200 px-3 py-2.5 text-slate-800"
disabled={!ownerPersonId}
>
<option value="">Leave unlinked — {summary.matchName || 'unknown match'}</option>
{treePeople
.filter((person) => person.id !== ownerPersonId)
.map((person) => (
<option key={person.id} value={person.id}>
{[person.first_name, person.last_name].filter(Boolean).join(' ')}
</option>
))}
</select>
{lockedCounterpartId ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 space-y-1">
<p className="font-semibold text-slate-800">
{profileDisplayName || 'This profile'}
</p>
<p className="text-xs text-slate-500">
Links CSV match <span className="font-semibold">{summary.matchName}</span> to this
person&apos;s DNA record. The autosomal tester above is whoever ran the comparison
(e.g. a parent&apos;s kit).
</p>
</div>
) : (
<select
value={counterpartPersonId}
onChange={(event) => setCounterpartPersonId(event.target.value)}
className="w-full rounded-xl border border-slate-200 px-3 py-2.5 text-slate-800"
disabled={!ownerPersonId}
>
<option value="">Leave unlinked — {summary.matchName || 'unknown match'}</option>
{treePeople
.filter((person) => person.id !== ownerPersonId)
.map((person) => (
<option key={person.id} value={person.id}>
{[person.first_name, person.last_name].filter(Boolean).join(' ')}
</option>
))}
</select>
)}
</label>

{offerMarriedAlias && (
Expand Down
Loading
Loading