Skip to content
Merged
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
14 changes: 14 additions & 0 deletions components/PersonProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,19 @@ const PersonProfile: React.FC<PersonProfileProps> = ({
[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]);
Expand Down Expand Up @@ -1550,6 +1563,7 @@ const PersonProfile: React.FC<PersonProfileProps> = ({
onAddTest={handleAddDNATest}
onUpdateTest={handleUpdateDnaTest}
onRemoveTest={handleRemoveDnaTest}
onOpenPersonId={handleOpenPersonId}
onAddMarriedNameAlias={(fullName) => {
const parts = fullName.trim().split(/\s+/);
const firstName = parts[0] || '';
Expand Down
159 changes: 157 additions & 2 deletions components/person-profile/DNATab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +36,7 @@ interface DNATabProps {
onUpdateTest: (id: string, updates: Partial<DNATest>) => 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
Expand Down Expand Up @@ -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 (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span>Kit owner:</span>
<select
value={kitOwnerPersonId || ''}
disabled={loadingPeople || savingKitOwner}
onChange={(e) => {
const nextOwnerId = e.target.value;
if (nextOwnerId) onKitOwnerChange(test, nextOwnerId);
}}
className="max-w-full rounded-lg border border-white/20 bg-slate-900/80 px-2 py-1 text-xs font-semibold text-white outline-none focus:border-blue-300"
>
<option value="" className="text-slate-900">
{kitOwnerDisplayName !== 'Unknown' ? kitOwnerDisplayName : 'Select kit owner…'}
</option>
{treePeople
.filter((row) => row.id !== personId)
.map((row) => (
<option key={row.id} value={row.id} className="text-slate-900">
{nameForTreePerson(treePeople, row.id) || row.id}
</option>
))}
</select>
{kitOwnerPersonId && kitOwnerPersonId !== personId && onOpenPersonId && (
<button
type="button"
onClick={() => onOpenPersonId(kitOwnerPersonId)}
className="text-[10px] font-black uppercase tracking-[0.15em] text-blue-200 underline decoration-dotted underline-offset-2 hover:text-blue-100"
>
Open profile
</button>
)}
{savingKitOwner && <span className="text-white/50">Saving…</span>}
</div>
{showSuggestion && (
<button
type="button"
disabled={savingKitOwner}
onClick={() => onKitOwnerChange(test, suggestedKitOwnerPersonId)}
className="text-[10px] text-amber-200 underline decoration-dotted underline-offset-2 hover:text-amber-100"
>
Use suggested kit owner: {suggestedName}
</button>
)}
</div>
);
};

const SharedAutosomalPartyLine: React.FC<{
label: string;
name: string;
personId?: string;
currentPersonId: string;
onOpenPersonId?: (personId: string) => void;
}> = ({ label, name, personId, currentPersonId, onOpenPersonId }) => (
<p>
{label}:{' '}
{personId && personId !== currentPersonId && onOpenPersonId ? (
<button
type="button"
onClick={() => onOpenPersonId(personId)}
className="font-semibold text-blue-200 underline decoration-dotted underline-offset-2 hover:text-blue-100"
>
{name}
</button>
) : (
<span className="font-semibold text-white">{name}</span>
)}
</p>
);

const DNATab: React.FC<DNATabProps> = ({
personId,
treeId,
Expand All @@ -87,6 +198,7 @@ const DNATab: React.FC<DNATabProps> = ({
onUpdateTest,
onRemoveTest,
onAddMarriedNameAlias,
onOpenPersonId,
}) => (
<DNATabInner
personId={personId}
Expand All @@ -97,6 +209,7 @@ const DNATab: React.FC<DNATabProps> = ({
onUpdateTest={onUpdateTest}
onRemoveTest={onRemoveTest}
onAddMarriedNameAlias={onAddMarriedNameAlias}
onOpenPersonId={onOpenPersonId}
/>
);

Expand All @@ -109,6 +222,7 @@ const DNATabInner: React.FC<Omit<DNATabProps, 'personNameCandidates'>> = ({
onUpdateTest,
onRemoveTest,
onAddMarriedNameAlias,
onOpenPersonId,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [importTargetId, setImportTargetId] = useState<string | null>(null);
Expand All @@ -135,6 +249,7 @@ const DNATabInner: React.FC<Omit<DNATabProps, 'personNameCandidates'>> = ({
preview: DNASharedSegmentRowPreview[];
}>
| null>(null);
const [savingKitOwnerTestId, setSavingKitOwnerTestId] = useState<string | null>(null);

useEffect(() => {
if (!treeId || !isSupabaseConfigured()) {
Expand Down Expand Up @@ -349,6 +464,21 @@ const DNATabInner: React.FC<Omit<DNATabProps, 'personNameCandidates'>> = ({
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 (
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-2 duration-300">
<DnaRawConsentModal
Expand Down Expand Up @@ -559,7 +689,32 @@ const DNATabInner: React.FC<Omit<DNATabProps, 'personNameCandidates'>> = ({
<FileText className="w-4 h-4 text-blue-300" />
{test.sharedSegmentSummary.fileName}
</p>
<p>Match: {test.sharedSegmentSummary.matchName}</p>
{(() => {
const parties = resolveSharedAutosomalParties(personId, test, treePeople);
return (
<>
<SharedKitOwnerField
test={test}
personId={personId}
treePeople={treePeople}
kitOwnerPersonId={parties.kitOwner.personId}
kitOwnerDisplayName={parties.kitOwner.displayName}
suggestedKitOwnerPersonId={parties.suggestedKitOwnerPersonId}
loadingPeople={loadingTreePeople}
savingKitOwner={savingKitOwnerTestId === test.id}
onOpenPersonId={onOpenPersonId}
onKitOwnerChange={handleKitOwnerChange}
/>
<SharedAutosomalPartyLine
label="Match"
name={parties.match.displayName}
personId={parties.match.personId}
currentPersonId={personId}
onOpenPersonId={onOpenPersonId}
/>
</>
);
})()}
<p>
{test.sharedSegmentSummary.segmentCount} segments • {test.sharedSegmentSummary.totalCentimorgans.toFixed(1)} cM total •{' '}
{test.sharedSegmentSummary.largestSegmentCentimorgans.toFixed(1)} cM largest
Expand Down
12 changes: 12 additions & 0 deletions lib/dnaRawParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
22 changes: 20 additions & 2 deletions lib/dnaRawParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -102,14 +105,29 @@ 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;
}
}
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 };
};

Expand Down
20 changes: 20 additions & 0 deletions lib/dnaSharedImportOwner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
inferCounterpartDisplayName,
resolveSharedMatchCounterpartLabel,
sharedTestAppliesToFocusPerson,
suggestKitOwnerPersonId,
} from './dnaSharedImportOwner';
Expand Down Expand Up @@ -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');
});
});
34 changes: 34 additions & 0 deletions lib/dnaSharedImportOwner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading
Loading