diff --git a/components/PersonProfile.tsx b/components/PersonProfile.tsx index 9da6575..c77055a 100644 --- a/components/PersonProfile.tsx +++ b/components/PersonProfile.tsx @@ -310,6 +310,19 @@ const PersonProfile: React.FC = ({ [person, applyConnectionData, canEditTree, currentUser] ); + const handleOpenPersonId = useCallback( + async (personId: string) => { + if (!onNavigateToPerson || personId === person.id) return; + try { + const next = await fetchPersonDetails(personId); + if (next) onNavigateToPerson(next); + } catch (err) { + console.error('Failed to open linked person from DNA tab', err); + } + }, + [onNavigateToPerson, person.id] + ); + useEffect(() => { setRelationPeople((prev) => ({ ...prev, [person.id]: person })); }, [person]); @@ -1550,6 +1563,7 @@ const PersonProfile: React.FC = ({ onAddTest={handleAddDNATest} onUpdateTest={handleUpdateDnaTest} onRemoveTest={handleRemoveDnaTest} + onOpenPersonId={handleOpenPersonId} onAddMarriedNameAlias={(fullName) => { const parts = fullName.trim().split(/\s+/); const firstName = parts[0] || ''; diff --git a/components/person-profile/DNATab.tsx b/components/person-profile/DNATab.tsx index ad1201b..c2568cf 100644 --- a/components/person-profile/DNATab.tsx +++ b/components/person-profile/DNATab.tsx @@ -18,12 +18,13 @@ import { import DnaRawConsentModal from '../dna/DnaRawConsentModal'; import SharedSegmentImportModal from '../dna/SharedSegmentImportModal'; import HaplogroupMigrationCard from '../dna/HaplogroupMigrationCard'; -import { purgeDnaRawData } from '../../services/archive'; +import { purgeDnaRawData, updateSharedAutosomalKitOwner } from '../../services/archive'; import { isSupabaseConfigured, supabase } from '../../lib/supabase'; import { mapDbRowToNameLookup } from '../../lib/dnaPersonNameVariants'; import type { DNASharedSegmentRowPreview, DNASharedSegmentSummary } from '../../types'; import type { SharedImportNameRow } from '../../lib/dnaSharedImportOwner'; import type { SharedSegmentImportConfirmPayload } from '../dna/SharedSegmentImportModal'; +import { resolveSharedAutosomalParties } from '../../lib/dnaSharedTestParties'; interface DNATabProps { personId: string; @@ -35,6 +36,7 @@ interface DNATabProps { onUpdateTest: (id: string, updates: Partial) => void; onRemoveTest: (id: string) => void; onAddMarriedNameAlias?: (fullName: string) => void; + onOpenPersonId?: (personId: string) => void; } // Resolved-lineage status shown on the profile DNA tab. Mirrors the admin DNA panel's @@ -78,6 +80,115 @@ const SharedLineageStatusBadge: React.FC<{ ); }; +const nameForTreePerson = (people: SharedImportNameRow[], id: string) => { + const row = people.find((entry) => entry.id === id); + if (!row) return null; + return [row.first_name, row.last_name].filter(Boolean).join(' ').trim() || null; +}; + +const SharedKitOwnerField: React.FC<{ + test: DNATest; + personId: string; + treePeople: SharedImportNameRow[]; + kitOwnerPersonId?: string; + kitOwnerDisplayName: string; + suggestedKitOwnerPersonId: string | null; + loadingPeople: boolean; + savingKitOwner: boolean; + onOpenPersonId?: (personId: string) => void; + onKitOwnerChange: (test: DNATest, ownerPersonId: string) => void; +}> = ({ + test, + personId, + treePeople, + kitOwnerPersonId, + kitOwnerDisplayName, + suggestedKitOwnerPersonId, + loadingPeople, + savingKitOwner, + onOpenPersonId, + onKitOwnerChange, +}) => { + const suggestedName = suggestedKitOwnerPersonId + ? nameForTreePerson(treePeople, suggestedKitOwnerPersonId) + : null; + const showSuggestion = + suggestedKitOwnerPersonId && + suggestedKitOwnerPersonId !== kitOwnerPersonId && + suggestedName; + + return ( +
+
+ Kit owner: + + {kitOwnerPersonId && kitOwnerPersonId !== personId && onOpenPersonId && ( + + )} + {savingKitOwner && Saving…} +
+ {showSuggestion && ( + + )} +
+ ); +}; + +const SharedAutosomalPartyLine: React.FC<{ + label: string; + name: string; + personId?: string; + currentPersonId: string; + onOpenPersonId?: (personId: string) => void; +}> = ({ label, name, personId, currentPersonId, onOpenPersonId }) => ( +

+ {label}:{' '} + {personId && personId !== currentPersonId && onOpenPersonId ? ( + + ) : ( + {name} + )} +

+); + const DNATab: React.FC = ({ personId, treeId, @@ -87,6 +198,7 @@ const DNATab: React.FC = ({ onUpdateTest, onRemoveTest, onAddMarriedNameAlias, + onOpenPersonId, }) => ( = ({ onUpdateTest={onUpdateTest} onRemoveTest={onRemoveTest} onAddMarriedNameAlias={onAddMarriedNameAlias} + onOpenPersonId={onOpenPersonId} /> ); @@ -109,6 +222,7 @@ const DNATabInner: React.FC> = ({ onUpdateTest, onRemoveTest, onAddMarriedNameAlias, + onOpenPersonId, }) => { const fileInputRef = useRef(null); const [importTargetId, setImportTargetId] = useState(null); @@ -135,6 +249,7 @@ const DNATabInner: React.FC> = ({ preview: DNASharedSegmentRowPreview[]; }> | null>(null); + const [savingKitOwnerTestId, setSavingKitOwnerTestId] = useState(null); useEffect(() => { if (!treeId || !isSupabaseConfigured()) { @@ -349,6 +464,21 @@ const DNATabInner: React.FC> = ({ setImportMode(null); }; + const handleKitOwnerChange = async (test: DNATest, ownerPersonId: string) => { + if (!ownerPersonId || ownerPersonId === personId) return; + onUpdateTest(test.id, { sharedPersonId: ownerPersonId }); + if (!UUID_REGEX.test(test.id)) return; + setSavingKitOwnerTestId(test.id); + setImportError(null); + try { + await updateSharedAutosomalKitOwner(test.id, ownerPersonId); + } catch (err) { + setImportError(err instanceof Error ? err.message : 'Could not update kit owner.'); + } finally { + setSavingKitOwnerTestId(null); + } + }; + return (
> = ({ {test.sharedSegmentSummary.fileName}

-

Match: {test.sharedSegmentSummary.matchName}

+ {(() => { + const parties = resolveSharedAutosomalParties(personId, test, treePeople); + return ( + <> + + + + ); + })()}

{test.sharedSegmentSummary.segmentCount} segments • {test.sharedSegmentSummary.totalCentimorgans.toFixed(1)} cM total •{' '} {test.sharedSegmentSummary.largestSegmentCentimorgans.toFixed(1)} cM largest diff --git a/lib/dnaRawParser.test.ts b/lib/dnaRawParser.test.ts index ef8fcf6..23167b9 100644 --- a/lib/dnaRawParser.test.ts +++ b/lib/dnaRawParser.test.ts @@ -93,6 +93,18 @@ describe('parseSharedSegmentsCsv', () => { expect(summary.matchName).toBe('Torb Ellefsen'); }); + it('ignores spurious FTDNA MATCH NAME cells that are not filename parties', () => { + const csv = [ftdnaHeader, 'Nis Rasmussen,3,1000,9000,28.1,500'].join('\n'); + + const { summary } = parseSharedSegmentsCsv( + csv, + 'Shared DNA segments of Pernille Gamby and E G Kazanis.csv' + ); + + expect(summary.personName).toBe('Pernille Gamby'); + expect(summary.matchName).toBe('E G Kazanis'); + }); + it('extracts comparison names from shared-segment filenames', () => { expect( extractComparisonNamesFromFileName('Shared DNA segments of Simon Ellefsen and Torb Ellefsen.csv') diff --git a/lib/dnaRawParser.ts b/lib/dnaRawParser.ts index fe68bfb..54337fd 100644 --- a/lib/dnaRawParser.ts +++ b/lib/dnaRawParser.ts @@ -86,7 +86,10 @@ const namesLookSimilar = (left: string, right: string) => { return a === b || a.includes(b) || b.includes(a); }; -const applyFileNameComparisonNames = ( +const nameLooksLikeFilenameParty = (name: string, firstName: string, secondName: string) => + namesLookSimilar(name, firstName) || namesLookSimilar(name, secondName); + +export const applyFileNameComparisonNames = ( fileName: string, personName: string, matchName: string, @@ -102,7 +105,13 @@ const applyFileNameComparisonNames = ( if (!nextPersonName || nextPersonName === 'Unknown') { if (isFtdnaComparison && nextMatchName && nextMatchName !== 'Unknown') { - nextPersonName = namesLookSimilar(nextMatchName, secondName) ? firstName : secondName; + if (namesLookSimilar(nextMatchName, secondName)) { + nextPersonName = firstName; + } else if (namesLookSimilar(nextMatchName, firstName)) { + nextPersonName = secondName; + } else { + nextPersonName = firstName; + } } else { nextPersonName = firstName; } @@ -110,6 +119,15 @@ const applyFileNameComparisonNames = ( if (!nextMatchName || nextMatchName === 'Unknown') { nextMatchName = secondName; } + + // FTDNA comparison rows often carry a segment label in MATCH NAME, not the other tester. + if (!nameLooksLikeFilenameParty(nextMatchName, firstName, secondName)) { + nextMatchName = namesLookSimilar(nextPersonName, firstName) ? secondName : firstName; + } + if (!nameLooksLikeFilenameParty(nextPersonName, firstName, secondName)) { + nextPersonName = namesLookSimilar(nextMatchName, firstName) ? secondName : firstName; + } + return { personName: nextPersonName, matchName: nextMatchName }; }; diff --git a/lib/dnaSharedImportOwner.test.ts b/lib/dnaSharedImportOwner.test.ts index 42dd755..31449ce 100644 --- a/lib/dnaSharedImportOwner.test.ts +++ b/lib/dnaSharedImportOwner.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { inferCounterpartDisplayName, + resolveSharedMatchCounterpartLabel, sharedTestAppliesToFocusPerson, suggestKitOwnerPersonId, } from './dnaSharedImportOwner'; @@ -60,4 +61,23 @@ describe('dnaSharedImportOwner', () => { }, 'Helle Andersen') ).toBe('Ruben Lykke Pedersen'); }); + + it('prefers kit owner name over stale RPC join when ids align', () => { + const kazanisId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const nisId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + expect( + resolveSharedMatchCounterpartLabel( + 'Pernille Gether Gamby', + kazanisId, + { first_name: 'E. G.', last_name: 'Kazanis' }, + kazanisId, + null, + { + personName: 'Pernille Gamby', + matchName: 'Nis Rasmussen', + }, + nisId + ) + ).toBe('E. G. Kazanis'); + }); }); diff --git a/lib/dnaSharedImportOwner.ts b/lib/dnaSharedImportOwner.ts index 98d2af2..b72e1b9 100644 --- a/lib/dnaSharedImportOwner.ts +++ b/lib/dnaSharedImportOwner.ts @@ -97,6 +97,40 @@ export const inferCounterpartDisplayName = ( return summary.matchName || summary.personName || 'Unknown match'; }; +const rowDisplayName = (row?: { first_name?: string | null; last_name?: string | null } | null) => + row ? [row.first_name, row.last_name].filter(Boolean).join(' ').trim() : ''; + +/** Display label for the non-focus party in a shared-segment match list row. */ +export const resolveSharedMatchCounterpartLabel = ( + focusFullName: string, + ownerPersonId: string, + ownerRow: { first_name?: string | null; last_name?: string | null }, + counterpartPersonId: string | null, + counterpartRow: { first_name?: string | null; last_name?: string | null } | null, + summary: SharedSegmentSummaryNames, + staleRpcCounterpartId?: string | null +): string => { + if (counterpartPersonId && counterpartPersonId === ownerPersonId) { + const ownerDisplay = rowDisplayName(ownerRow); + if (ownerDisplay) return ownerDisplay; + } + if ( + counterpartPersonId && + counterpartRow && + (!staleRpcCounterpartId || staleRpcCounterpartId === counterpartPersonId) + ) { + const display = rowDisplayName(counterpartRow); + if (display) return display; + } + if (scoreNameMatch(focusFullName, summary.personName) >= 60 && summary.matchName?.trim()) { + return summary.matchName.trim(); + } + if (scoreNameMatch(focusFullName, summary.matchName) >= 60 && summary.personName?.trim()) { + return summary.personName.trim(); + } + return summary.matchName || summary.personName || rowDisplayName(ownerRow) || 'Unknown'; +}; + const displayNameForRow = (row: SharedImportNameRow) => [row.first_name, row.last_name].filter(Boolean).join(' ').trim(); diff --git a/lib/dnaSharedTestParties.test.ts b/lib/dnaSharedTestParties.test.ts new file mode 100644 index 0000000..8d9f11a --- /dev/null +++ b/lib/dnaSharedTestParties.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSharedAutosomalParties } from './dnaSharedTestParties'; + +const HELLE = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const PERNILLE = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const BIRGITTA = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const MARIANNE = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + +const treePeople = [ + { id: HELLE, first_name: 'Helle', last_name: 'Andersen', maiden_name: 'Andersen' }, + { id: PERNILLE, first_name: 'Pernille', last_name: 'Gether Gamby', maiden_name: null }, + { id: BIRGITTA, first_name: 'Birgitta', last_name: 'Hallgren', maiden_name: 'Svensson' }, + { id: MARIANNE, first_name: 'Marianne', last_name: 'Gamby', maiden_name: null }, +]; + +describe('resolveSharedAutosomalParties', () => { + const helleTest = { + sharedPersonId: HELLE, + sharedMatchPersonId: BIRGITTA, + sharedMatchName: 'Birgitta Svensson Hallgren', + sharedSegmentSummary: { + personName: 'Helle Due', + matchName: 'Birgitta Svensson Hallgren', + fileName: 'Shared DNA segments of Helle Due and Birgitta Svensson Hallgren.csv', + segmentCount: 2, + totalCentimorgans: 38.2, + largestSegmentCentimorgans: 25.9, + importedAt: '2026-07-05T00:00:00.000Z', + source: 'FTDNA_SHARED_AUTOSOMAL_SEGMENTS_CSV' as const, + totalSnps: 0, + }, + }; + + const pernilleTest = { + sharedPersonId: PERNILLE, + sharedMatchPersonId: BIRGITTA, + sharedMatchName: 'Birgitta Svensson Hallgren', + sharedSegmentSummary: { + personName: 'Pernille Gether Gamby', + matchName: 'Birgitta Svensson Hallgren', + fileName: 'Shared DNA segments of Pernille Gether Gamby and Birgitta Svensson Hallgren.csv', + segmentCount: 3, + totalCentimorgans: 41.5, + largestSegmentCentimorgans: 18.2, + importedAt: '2026-07-05T00:00:00.000Z', + source: 'FTDNA_SHARED_AUTOSOMAL_SEGMENTS_CSV' as const, + totalSnps: 0, + }, + }; + + it('resolves distinct kit owners for multiple tests on the match profile', () => { + const helleView = resolveSharedAutosomalParties(BIRGITTA, helleTest, treePeople); + const pernilleView = resolveSharedAutosomalParties(BIRGITTA, pernilleTest, treePeople); + + expect(helleView.kitOwner.personId).toBe(HELLE); + expect(helleView.kitOwner.displayName).toBe('Helle Andersen'); + expect(pernilleView.kitOwner.personId).toBe(PERNILLE); + expect(pernilleView.kitOwner.displayName).toBe('Pernille Gether Gamby'); + expect(helleView.match.personId).toBe(BIRGITTA); + expect(pernilleView.match.personId).toBe(BIRGITTA); + }); + + it('replaces a wrong stored kit owner when it does not match the CSV name', () => { + const view = resolveSharedAutosomalParties( + BIRGITTA, + { + sharedPersonId: MARIANNE, + sharedMatchPersonId: BIRGITTA, + sharedSegmentSummary: { + personName: 'Pernille Gamby', + matchName: 'Birgitta Svensson Hallgren', + fileName: 'Shared DNA segments of Pernille Gamby and Birgitta Svensson Hallgren.csv', + segmentCount: 2, + totalCentimorgans: 36, + largestSegmentCentimorgans: 28.6, + importedAt: '2026-07-05T00:00:00.000Z', + source: 'FTDNA_SHARED_AUTOSOMAL_SEGMENTS_CSV' as const, + totalSnps: 0, + }, + }, + treePeople + ); + expect(view.kitOwner.personId).toBe(PERNILLE); + expect(view.kitOwner.displayName).toBe('Pernille Gether Gamby'); + expect(view.suggestedKitOwnerPersonId).toBe(PERNILLE); + }); + + it('infers kit owner from CSV when only names are stored', () => { + const view = resolveSharedAutosomalParties( + BIRGITTA, + { + sharedSegmentSummary: helleTest.sharedSegmentSummary, + }, + treePeople + ); + expect(view.kitOwner.displayName).toBe('Helle Andersen'); + expect(view.kitOwner.personId).toBe(HELLE); + expect(view.match.displayName).toBe('Birgitta Hallgren'); + expect(view.match.personId).toBe(BIRGITTA); + }); +}); diff --git a/lib/dnaSharedTestParties.ts b/lib/dnaSharedTestParties.ts new file mode 100644 index 0000000..fe46433 --- /dev/null +++ b/lib/dnaSharedTestParties.ts @@ -0,0 +1,188 @@ +import { scoreNameMatch } from './dnaNameMatch'; +import { applyFileNameComparisonNames } from './dnaRawParser'; +import { + suggestKitOwnerPersonId, + type SharedImportNameRow, + type SharedSegmentSummaryNames, +} from './dnaSharedImportOwner'; +import type { DNATest } from '../types'; + +export interface SharedAutosomalParty { + role: 'kit_owner' | 'match'; + personId?: string; + displayName: string; +} + +export interface SharedAutosomalPartyView { + kitOwner: SharedAutosomalParty; + match: SharedAutosomalParty; + suggestedKitOwnerPersonId: string | null; +} + +const displayNameForRow = (row: SharedImportNameRow) => + [row.first_name, row.last_name].filter(Boolean).join(' ').trim(); + +const displayNameForId = ( + personId: string | undefined, + fallback: string | undefined, + treePeople: SharedImportNameRow[] +): string => { + if (personId) { + const row = treePeople.find((entry) => entry.id === personId); + if (row) { + const resolved = displayNameForRow(row); + if (resolved) return resolved; + } + } + return fallback?.trim() || 'Unknown'; +}; + +const viewerMatchesName = ( + viewingPersonId: string, + rawName: string | undefined, + treePeople: SharedImportNameRow[] +): boolean => { + if (!rawName?.trim()) return false; + const viewerRow = treePeople.find((entry) => entry.id === viewingPersonId); + if (!viewerRow) return false; + const viewerNames = [displayNameForRow(viewerRow)]; + return viewerNames.some((name) => name && scoreNameMatch(name, rawName) >= 60); +}; + +export const suggestSharedAutosomalKitOwnerId = ( + viewingPersonId: string, + test: Pick, + treePeople: SharedImportNameRow[] +): string | null => { + const summary = test.sharedSegmentSummary; + if (!summary) return null; + const excluded = new Set( + [viewingPersonId, test.sharedMatchPersonId].filter( + (id): id is string => typeof id === 'string' && !!id + ) + ); + const candidates = treePeople.filter((row) => !excluded.has(row.id)); + const names: SharedSegmentSummaryNames = { + personName: summary.personName, + matchName: summary.matchName, + fileName: summary.fileName, + }; + return suggestKitOwnerPersonId(names, candidates); +}; + +const tokenizeDisplayName = (value: string) => + value + .normalize('NFD') + .replace(/\p{M}/gu, '') + .replace(/[^a-zA-Z0-9æøåÆØÅ\s-]/g, ' ') + .toLowerCase() + .split(/\s+/) + .map((token) => token.trim()) + .filter(Boolean); + +const storedOwnerMatchesCsv = ( + ownerPersonId: string | undefined, + csvOwnerName: string | undefined, + treePeople: SharedImportNameRow[] +): boolean => { + if (!ownerPersonId || !csvOwnerName?.trim()) return false; + const row = treePeople.find((entry) => entry.id === ownerPersonId); + if (!row) return false; + const display = displayNameForRow(row); + if (!display) return false; + const score = scoreNameMatch(display, csvOwnerName); + if (score >= 1000) return true; + const csvTokens = tokenizeDisplayName(csvOwnerName); + const displayTokens = tokenizeDisplayName(display); + if (!csvTokens.length || !displayTokens.length) return false; + const firstNameAligned = csvTokens[0] === displayTokens[0]; + return firstNameAligned && scoreNameMatch(display, csvOwnerName) >= 60; +}; + +/** + * Resolve kit-owner vs match for one shared-segment test. + * Each test carries its own owner (supports multiple imports per match person). + */ +export const resolveSharedAutosomalParties = ( + viewingPersonId: string, + test: Pick< + DNATest, + 'sharedPersonId' | 'sharedMatchPersonId' | 'sharedMatchName' | 'sharedSegmentSummary' + >, + treePeople: SharedImportNameRow[] +): SharedAutosomalPartyView => { + const summary = test.sharedSegmentSummary; + const isFtdnaComparison = summary?.importFormat === 'FTDNA_COMPARISON_SEGMENTS'; + const normalizedNames = summary?.fileName + ? applyFileNameComparisonNames( + summary.fileName, + summary.personName, + summary.matchName, + isFtdnaComparison + ) + : { personName: summary?.personName, matchName: summary?.matchName }; + const personName = normalizedNames.personName; + const matchName = test.sharedMatchName || normalizedNames.matchName; + const suggestedKitOwnerPersonId = suggestSharedAutosomalKitOwnerId(viewingPersonId, test, treePeople); + + const viewerIsKitOwner = + test.sharedPersonId === viewingPersonId || viewerMatchesName(viewingPersonId, personName, treePeople); + const viewerIsMatch = + test.sharedMatchPersonId === viewingPersonId || viewerMatchesName(viewingPersonId, matchName, treePeople); + + let kitOwnerId = test.sharedPersonId; + let matchPersonId = test.sharedMatchPersonId; + + if (kitOwnerId === viewingPersonId && viewerIsMatch) { + kitOwnerId = undefined; + } + if ( + kitOwnerId && + viewerIsMatch && + personName && + !storedOwnerMatchesCsv(kitOwnerId, personName, treePeople) + ) { + kitOwnerId = undefined; + } + + if (!kitOwnerId) { + kitOwnerId = suggestedKitOwnerPersonId ?? undefined; + } + + if (!kitOwnerId) { + if (viewerIsMatch && personName) { + kitOwnerId = + treePeople.find( + (row) => + row.id !== viewingPersonId && scoreNameMatch(displayNameForRow(row), personName) >= 60 + )?.id ?? undefined; + } else if (viewerIsKitOwner) { + kitOwnerId = viewingPersonId; + } + } + + if (!matchPersonId) { + if (viewerIsKitOwner && matchName) { + matchPersonId = + treePeople.find( + (row) => + row.id !== viewingPersonId && scoreNameMatch(displayNameForRow(row), matchName) >= 60 + )?.id ?? undefined; + } else if (viewerIsMatch) { + matchPersonId = viewingPersonId; + } + } + + const kitOwner: SharedAutosomalParty = { + role: 'kit_owner', + personId: kitOwnerId, + displayName: displayNameForId(kitOwnerId, personName, treePeople), + }; + const match: SharedAutosomalParty = { + role: 'match', + personId: matchPersonId, + displayName: displayNameForId(matchPersonId, matchName, treePeople), + }; + + return { kitOwner, match, suggestedKitOwnerPersonId }; +}; diff --git a/lib/publicSlugs.test.ts b/lib/publicSlugs.test.ts index da735fe..b33647a 100644 --- a/lib/publicSlugs.test.ts +++ b/lib/publicSlugs.test.ts @@ -7,6 +7,7 @@ describe('publicSlugs', () => { it('slugifies Danish tree names', () => { expect(slugifyTreeName('Gether-Gamby')).toBe('gether-gamby'); expect(slugifyTreeName('Hass-Jensen')).toBe('hass-jensen'); + expect(slugifyTreeName('Gøth-Tunsted')).toBe('goeth-tunsted'); }); it('builds person slug segments with id8 suffix', () => { diff --git a/services/archive/dna.ts b/services/archive/dna.ts index d7293bb..88d26ab 100644 --- a/services/archive/dna.ts +++ b/services/archive/dna.ts @@ -1,6 +1,6 @@ import { supabase, isSupabaseConfigured } from '../../lib/supabase'; import { deriveMatchConfidence, relationshipPredictionLabel, supportsRelationshipHops } from '../../lib/dnaClassification'; -import { extractComparisonNamesFromFileName } from '../../lib/dnaRawParser'; +import { applyFileNameComparisonNames, extractComparisonNamesFromFileName } from '../../lib/dnaRawParser'; import { DNA_BLOOD_PATH_RELATIONSHIP_TYPES, buildChildToParentsMap, @@ -16,7 +16,7 @@ import { mapDbRowToNameLookup, } from '../../lib/dnaPersonNameVariants'; import { - inferCounterpartDisplayName, + resolveSharedMatchCounterpartLabel, sharedTestAppliesToFocusPerson, } from '../../lib/dnaSharedImportOwner'; import { @@ -211,37 +211,7 @@ const summaryFromDnaTestMetadata = (metadata: Record): SharedSe : undefined; const isFtdnaComparison = importFormat === 'FTDNA_COMPARISON_SEGMENTS'; const resolvedNames = fileName - ? (() => { - const fromFile = extractComparisonNamesFromFileName(fileName); - if (!fromFile) return { personName, matchName }; - const [firstName, secondName] = fromFile; - let nextPersonName = personName; - let nextMatchName = matchName; - if (!nextPersonName || nextPersonName === 'Unknown') { - if (isFtdnaComparison && nextMatchName && nextMatchName !== 'Unknown') { - const normalize = (value: string) => - value - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-zA-Z0-9\s-]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .toLowerCase(); - const matchNorm = normalize(nextMatchName); - const secondNorm = normalize(secondName); - nextPersonName = - matchNorm === secondNorm || matchNorm.includes(secondNorm) || secondNorm.includes(matchNorm) - ? firstName - : secondName; - } else { - nextPersonName = firstName; - } - } - if (!nextMatchName || nextMatchName === 'Unknown') { - nextMatchName = secondName; - } - return { personName: nextPersonName, matchName: nextMatchName }; - })() + ? applyFileNameComparisonNames(fileName, personName, matchName, isFtdnaComparison) : { personName, matchName }; return { source: typeof summaryRaw.source === 'string' ? summaryRaw.source : undefined, @@ -745,13 +715,6 @@ export const listSharedMatchesForAutosomalPerson = async ( typeof testRow.counterpart_person_id === 'string' && UUID_REGEX.test(testRow.counterpart_person_id) ? testRow.counterpart_person_id : null; - const rpcCounterpartRow = - rpcCounterpartId && (testRow.counterpart_first_name || testRow.counterpart_last_name) - ? ({ - first_name: testRow.counterpart_first_name || '', - last_name: testRow.counterpart_last_name || '', - } as any) - : null; let counterpartPersonId: string | null = null; const summary = summaryFromDnaTestMetadata(metadata); if (!summary) return; @@ -808,6 +771,15 @@ export const listSharedMatchesForAutosomalPerson = async ( focusFullName ); } + if ( + ownerPersonId && + ownerPersonId !== focusPersonId && + summary && + (scoreNameMatch(focusFullName, summary.personName) >= 60 || + scoreNameMatch(focusFullName, summary.matchName) >= 60) + ) { + counterpartPersonId = ownerPersonId; + } if (counterpartPersonId === focusPersonId) return; const pairKey = counterpartPersonId ? [focusPersonId, counterpartPersonId].sort().join(':') @@ -830,17 +802,17 @@ export const listSharedMatchesForAutosomalPerson = async ( summary.totalCentimorgans ); - const counterpartNameFromPeople = + const counterpartPersonName = resolveSharedMatchCounterpartLabel( + focusFullName, + ownerPersonId, + ownerPersonRow, + counterpartPersonId, counterpartPersonId && personById.has(counterpartPersonId) - ? toDisplayName(personById.get(counterpartPersonId)) - : null; - const counterpartNameFromRpc = rpcCounterpartRow ? toDisplayName(rpcCounterpartRow) : null; - const counterpartPersonName = - counterpartNameFromPeople || - counterpartNameFromRpc || - inferCounterpartDisplayName(focusPersonId, ownerPersonId, summary, focusFullName) || - summary.matchName || - 'Unknown'; + ? personById.get(counterpartPersonId)! + : null, + summary, + rpcCounterpartId + ); const nameSuggestion = !counterpartPersonId ? findBestNameMatch(counterpartPersonName, nameRows, focusPersonId) : null; @@ -1913,3 +1885,38 @@ export const relinkSharedAutosomalTestOwner = async ( .eq('id', dnaTestId); if (updateError) throw new Error(updateError.message); }; + +/** Update kit owner on a shared-segment test without moving the test row to another profile. */ +export const updateSharedAutosomalKitOwner = async ( + dnaTestId: string, + ownerPersonId: string +): Promise => { + if (!isSupabaseConfigured()) { + throw new Error('Supabase credentials are missing.'); + } + if (!UUID_REGEX.test(dnaTestId) || !UUID_REGEX.test(ownerPersonId)) { + throw new Error('Invalid DNA test or owner id.'); + } + const { data: testRow, error: readError } = await supabase + .from('dna_tests') + .select('id, test_type, metadata, person_id') + .eq('id', dnaTestId) + .maybeSingle(); + if (readError) throw new Error(readError.message); + if (!testRow || testRow.test_type !== 'Shared Autosomal') { + throw new Error('Shared autosomal test not found.'); + } + const metadata = asRecord(testRow.metadata); + const { error: updateError } = await supabase + .from('dna_tests') + .update({ + shared_person_id: ownerPersonId, + metadata: { + ...metadata, + sharedPersonId: ownerPersonId, + shared_person_id: ownerPersonId, + }, + }) + .eq('id', dnaTestId); + if (updateError) throw new Error(updateError.message); +}; diff --git a/services/archive/persons.ts b/services/archive/persons.ts index 5d873b5..a26c168 100644 --- a/services/archive/persons.ts +++ b/services/archive/persons.ts @@ -418,6 +418,12 @@ export const updatePersonProfile = async ( sharedMatchPersonId = personId; } } + if (!sharedPersonId && summaryMatchLooksLikeFocus && summaryPersonId && summaryPersonId !== personId) { + sharedPersonId = summaryPersonId; + } + if (!sharedMatchPersonId && summaryPersonLooksLikeFocus && summaryMatchId && summaryMatchId !== personId) { + sharedMatchPersonId = summaryMatchId; + } if (!sharedPersonId && sharedMatchPersonId === personId && uniqueAutosomalTesterId && uniqueAutosomalTesterId !== personId) { // FTDNA comparison CSV omits tester name; when exactly one autosomal tester exists // in this tree, bind that tester as the shared-person side. diff --git a/supabase/migrations/20260705258000_rename_goeth_to_goeth_tunsted.sql b/supabase/migrations/20260705258000_rename_goeth_to_goeth_tunsted.sql new file mode 100644 index 0000000..98bbab5 --- /dev/null +++ b/supabase/migrations/20260705258000_rename_goeth_to_goeth_tunsted.sql @@ -0,0 +1,10 @@ +-- Rename Gøth → Gøth-Tunsted (display name + public URL slug). + +update public.family_trees +set + name = 'Gøth-Tunsted', + slug = 'goeth-tunsted', + updated_at = now() +where lower(name) = lower('Gøth'); + +select pg_notify('pgrst', 'reload schema');