// People β contact resolution + management screen.
// Two sections: Pending clusters (to resolve) + Confirmed people (to edit/merge).
const RELATIONSHIP_OPTIONS = [
{ value: 'family', label: 'Family', emoji: 'π¨βπ©βπ§' },
{ value: 'partner', label: 'Partner', emoji: 'π' },
{ value: 'friend', label: 'Friend', emoji: 'π₯' },
{ value: 'childhood', label: 'Childhood friend', emoji: 'π«' },
{ value: 'colleague', label: 'Colleague', emoji: 'πΌ' },
{ value: 'army', label: 'Army / Reserves', emoji: 'πͺ' },
{ value: 'acquaintance', label: 'Acquaintance', emoji: 'π€' },
{ value: 'pet', label: 'Pet', emoji: 'πΎ' },
{ value: 'unknown', label: 'Unknown', emoji: 'β' },
];
const REL_MAP = Object.fromEntries(RELATIONSHIP_OPTIONS.map(r => [r.value, r]));
const REL_COLORS = {
family: '#FF2D55',
partner: '#AF52DE',
friend: '#007AFF',
childhood: '#5856D6',
colleague: '#FF9500',
army: '#34C759',
acquaintance:'#5AC8FA',
pet: '#FF9500',
unknown: 'rgba(60,60,67,0.45)',
};
const USER = (new URLSearchParams(window.location.search)).get('user') || 'shay';
const API = '/api';
function api(path, opts = {}) {
return fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
}).then(r => r.json());
}
// ββ Relationship picker sheet βββββββββββββββββββββββββββββββββββββββββββββββββ
function RelPicker({ current, onPick, onClose }) {
return (
e.stopPropagation()} style={{
background: T.bg2, borderRadius: '18px 18px 0 0',
padding: '16px 16px 32px',
}}>
Relationship
{RELATIONSHIP_OPTIONS.map(r => (
onPick(r.value)} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '11px 0',
borderBottom: `1px solid ${T.sep}`,
cursor: 'pointer',
}}>
{r.emoji}
{r.label}
{current === r.value && (
β
)}
))}
);
}
// ββ Confirmed person card βββββββββββββββββββββββββββββββββββββββββββββββββββββ
function PersonCard({ person, mergeSelected, onMergeSelect, onUpdate }) {
const [editing, setEditing] = React.useState(false);
const [name, setName] = React.useState(person.canonical_name);
const [showRelPicker, setShowRelPicker] = React.useState(false);
const [notes, setNotes] = React.useState(person.notes || '');
const [showNotes, setShowNotes] = React.useState(!!(person.notes));
const [busy, setBusy] = React.useState(false);
const rel = REL_MAP[person.relationship_type] || REL_MAP.unknown;
const relColor = REL_COLORS[person.relationship_type] || REL_COLORS.unknown;
const isSelected = mergeSelected && mergeSelected.some(p => p.id === person.id);
const mergeMode = mergeSelected !== null;
const saveName = async () => {
setEditing(false);
if (name.trim() === person.canonical_name) return;
setBusy(true);
await api(`/people/${person.id}`, {
method: 'PATCH',
body: JSON.stringify({ user: USER, canonical_name: name.trim() }),
});
onUpdate();
setBusy(false);
};
const saveRel = async (val) => {
setShowRelPicker(false);
setBusy(true);
await api(`/people/${person.id}`, {
method: 'PATCH',
body: JSON.stringify({ user: USER, relationship_type: val }),
});
onUpdate();
setBusy(false);
};
const saveNotes = async () => {
await api(`/people/${person.id}`, {
method: 'PATCH',
body: JSON.stringify({ user: USER, notes: notes }),
});
};
const doSplitAlias = async (alias) => {
if (!confirm(`Split "${alias}" into a separate person?`)) return;
setBusy(true);
await api(`/people/${person.id}/split`, {
method: 'POST',
body: JSON.stringify({ user: USER, alias }),
});
onUpdate();
setBusy(false);
};
return (
<>
{showRelPicker && (
setShowRelPicker(false)} />
)}
onMergeSelect(person) : undefined}
style={{
background: T.bg2,
borderRadius: 14,
padding: '12px 14px',
marginBottom: 8,
opacity: busy ? 0.6 : 1,
border: isSelected ? `2px solid ${T.blue}` : mergeMode ? `2px solid ${T.sep}` : '2px solid transparent',
cursor: mergeMode ? 'pointer' : 'default',
transition: 'border 0.15s',
}}
>
{/* Header row */}
{mergeMode && (
{isSelected && β}
)}
{editing ? (
setName(e.target.value)}
onBlur={saveName}
onKeyDown={e => e.key === 'Enter' && saveName()}
style={{
flex: 1, fontSize: 16, fontWeight: 600, border: 'none', outline: 'none',
background: 'transparent', color: T.label,
}}
/>
) : (
{ e.stopPropagation(); if (!mergeMode) setEditing(true); }}
style={{ flex: 1, fontSize: 16, fontWeight: 600, color: T.label, cursor: mergeMode ? 'default' : 'text' }}
>
{person.canonical_name}
)}
{/* Relationship badge */}
{ e.stopPropagation(); if (!mergeMode) setShowRelPicker(true); }}
style={{
display: 'flex', alignItems: 'center', gap: 4,
background: relColor + '18', borderRadius: 999,
padding: '3px 8px',
cursor: mergeMode ? 'default' : 'pointer',
}}
>
{rel.emoji}
{rel.label}
{/* Aliases */}
{person.aliases && person.aliases.length > 0 && (
{person.aliases.slice(0, 12).map(a => (
{a}
{!mergeMode && (
{ e.stopPropagation(); doSplitAlias(a); }}
style={{ cursor: 'pointer', color: T.label3, marginLeft: 2, fontSize: 11 }}
>β
)}
))}
{person.aliases.length > 12 && (
+{person.aliases.length - 12}
)}
)}
{/* Notes area */}
{!mergeMode && showNotes && (
>
);
}
// ββ Pending cluster card βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function PendingCard({ cluster, confirmedPeople, onDone }) {
const [canonical, setCanonical] = React.useState(cluster.canonical);
const [rel, setRel] = React.useState('unknown');
const [showRelPicker, setShowRelPicker] = React.useState(false);
const [showMergeList, setShowMergeList] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const [suggesting, setSuggesting] = React.useState(false);
// Auto-apply Ollama suggestion if present
React.useEffect(() => {
if (cluster.suggestion && cluster.suggestion !== 'unknown') {
setRel(cluster.suggestion);
}
}, [cluster.suggestion]);
if (done) return null;
const confirm_ = async () => {
if (!rel || rel === 'unknown') { setShowRelPicker(true); return; }
setBusy(true);
await api('/people/pending/confirm', {
method: 'POST',
body: JSON.stringify({
user: USER,
cand_ids: cluster.cand_ids,
names: cluster.members,
canonical_name: canonical.trim() || cluster.canonical,
relationship_type: rel,
}),
});
setDone(true);
onDone();
setBusy(false);
};
const skip = async () => {
setBusy(true);
await api('/people/pending/skip', {
method: 'POST',
body: JSON.stringify({ user: USER, cand_ids: cluster.cand_ids }),
});
setDone(true);
onDone();
setBusy(false);
};
const mergeInto = async (person) => {
setBusy(true);
await api('/people/pending/merge-into', {
method: 'POST',
body: JSON.stringify({
user: USER,
cand_ids: cluster.cand_ids,
names: cluster.members,
person_id: person.id,
}),
});
setDone(true);
onDone();
setBusy(false);
};
const relInfo = REL_MAP[rel] || REL_MAP.unknown;
const relColor = REL_COLORS[rel] || REL_COLORS.unknown;
return (
<>
{showRelPicker && (
{ setRel(v); setShowRelPicker(false); }} onClose={() => setShowRelPicker(false)} />
)}
{/* Editable name */}
setCanonical(e.target.value)}
style={{
flex: 1, fontSize: 16, fontWeight: 600,
border: 'none', borderBottom: `1px solid ${T.sep}`,
outline: 'none', background: 'transparent',
color: T.label, padding: '2px 0',
}}
/>
{/* Rel badge */}
setShowRelPicker(true)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
background: relColor + '18', borderRadius: 999,
padding: '3px 8px', cursor: 'pointer',
}}
>
{relInfo.emoji}
{relInfo.label}
{/* Context: chat + message count */}
{(cluster.conversations?.length > 0 || cluster.msg_count > 0) && (
π¬ {cluster.msg_count} msgs
{cluster.conversations?.[0] ? ` Β· ${cluster.conversations[0]}` : ''}
)}
{/* Sample messages */}
{cluster.samples?.length > 0 && (
{cluster.samples.slice(0, 2).map((s, i) => (
"{s.length > 70 ? s.slice(0, 70) + 'β¦' : s}"
))}
)}
{/* Members */}
{cluster.members.slice(0, 12).map(m => (
{m}
))}
{cluster.members.length > 12 && (
+{cluster.members.length - 12} more
)}
{/* Actions */}
{/* Merge into list */}
{showMergeList && (
{confirmedPeople.length === 0 && (
No confirmed people yet
)}
{confirmedPeople.map(p => (
mergeInto(p)} style={{
padding: '9px 12px', fontSize: 14, cursor: 'pointer',
borderBottom: `1px solid ${T.sep}`,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{REL_MAP[p.relationship_type]?.emoji || 'β'}
{p.canonical_name}
))}
)}
>
);
}
function btnStyle(bg, color = '#fff', _unused) {
return {
flex: 1, padding: '8px 0', borderRadius: 10, border: 'none',
background: bg, color, fontSize: 13, fontWeight: 600,
cursor: 'pointer',
};
}
// ββ Search bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function SearchBar({ value, onChange }) {
return (
π
onChange(e.target.value)}
style={{
flex: 1, border: 'none', outline: 'none',
background: 'transparent', fontSize: 15, color: T.label,
}}
/>
{value && (
onChange('')} style={{ cursor: 'pointer', color: T.label3 }}>β
)}
);
}
// ββ Main screen βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function PeopleScreen({ go }) {
const [people, setPeople] = React.useState([]);
const [pending, setPending] = React.useState([]);
const [singlesCount, setSinglesCount] = React.useState(0);
const [skippedCount, setSkippedCount] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [search, setSearch] = React.useState('');
const [mergeSelected, setMergeSelected] = React.useState(null); // null = not in merge mode, [] = mode active
const [tab, setTab] = React.useState('pending');
const [skipping, setSkipping] = React.useState(false);
const [showSkipped, setShowSkipped] = React.useState(false);
const [merging, setMerging] = React.useState(false);
const load = async (includeSkipped = showSkipped) => {
setLoading(true);
const [ppl, pnd] = await Promise.all([
api(`/people?user=${USER}`),
api(`/people/pending?user=${USER}&include_skipped=${includeSkipped}`),
]);
setPeople(ppl.people || []);
setSinglesCount(pnd.singles_count || 0);
setSkippedCount(pnd.skipped_count || 0);
const clusters = pnd.clusters || [];
setPending(clusters);
setLoading(false);
// Fire Ollama suggestions for visible clusters without blocking render
clusters.slice(0, 10).forEach(async (cl, i) => {
if (cl.samples?.length || cl.conversations?.length) {
try {
const res = await api('/people/pending/suggest', {
method: 'POST',
body: JSON.stringify({
user: USER,
canonical: cl.canonical,
samples: cl.samples || [],
conversations: cl.conversations || [],
}),
});
if (res.suggestion && res.suggestion !== 'unknown') {
setPending(prev => prev.map((c, j) =>
j === i ? { ...c, suggestion: res.suggestion } : c
));
}
} catch {}
}
});
};
const skipSingles = async () => {
setSkipping(true);
await api(`/people/pending/skip-singles?user=${USER}`, { method: 'POST' });
await load();
setSkipping(false);
};
const toggleShowSkipped = async () => {
const next = !showSkipped;
setShowSkipped(next);
await load(next);
};
React.useEffect(() => { load(); }, []);
// Multi-merge: mergeSelected is null (not in mode) or an array of selected people
const handleMergeSelect = (person) => {
if (mergeSelected === null) {
setMergeSelected([person]);
return;
}
const already = mergeSelected.some(p => p.id === person.id);
if (already) {
const next = mergeSelected.filter(p => p.id !== person.id);
setMergeSelected(next.length === 0 ? [] : next);
} else {
setMergeSelected([...mergeSelected, person]);
}
};
const executeMerge = async () => {
if (!mergeSelected || mergeSelected.length < 2) return;
const keep = mergeSelected[0];
const discards = mergeSelected.slice(1);
const names = discards.map(p => p.canonical_name).join(', ');
if (!confirm(`Merge ${discards.length} person(s) into "${keep.canonical_name}"?\n\n${names} will be absorbed β their aliases and messages will be kept under "${keep.canonical_name}".`)) return;
setMerging(true);
for (const discard of discards) {
await api('/people/merge', {
method: 'POST',
body: JSON.stringify({ user: USER, keep_id: keep.id, discard_id: discard.id }),
});
}
setMergeSelected(null);
setMerging(false);
load();
};
const filtered = search.trim()
? people.filter(p =>
p.canonical_name.toLowerCase().includes(search.toLowerCase()) ||
(p.aliases || []).some(a => a.toLowerCase().includes(search.toLowerCase()))
)
: people;
const relGroups = {};
for (const p of filtered) {
const r = p.relationship_type || 'unknown';
if (!relGroups[r]) relGroups[r] = [];
relGroups[r].push(p);
}
const relOrder = RELATIONSHIP_OPTIONS.map(r => r.value);
return (
{/* Header */}
People
{mergeSelected !== null && (
<>
{mergeSelected.length === 0 ? 'Tap to select' : `${mergeSelected.length} selected`}
{mergeSelected.length >= 2 && (
)}
setMergeSelected(null)}
style={{ cursor: 'pointer', color: T.label3, fontSize: 18, lineHeight: 1 }}
>β
>
)}
{/* Tabs */}
{[
{ key: 'pending', label: `Pending${pending.length ? ` (${pending.length})` : ''}` },
{ key: 'confirmed', label: `Confirmed (${people.length})` },
].map(t => (
setTab(t.key)} style={{
flex: 1, textAlign: 'center', padding: '7px 0',
borderRadius: 8, fontSize: 13, fontWeight: 600,
background: tab === t.key ? T.bg2 : 'transparent',
color: tab === t.key ? T.label : T.label2,
cursor: 'pointer',
boxShadow: tab === t.key ? '0 1px 3px rgba(0,0,0,0.08)' : 'none',
transition: 'all 0.15s',
}}>
{t.label}
))}
{/* Body */}
{loading ? (
Loadingβ¦
) : tab === 'pending' ? (
<>
{singlesCount > 0 && (
{singlesCount} one-time contacts (noise)
)}
{skippedCount > 0 && (
{skippedCount} skipped with 2+ messages
)}
{pending.length === 0 ? (
{showSkipped ? 'No skipped contacts with 2+ messages' : 'All new contacts resolved β'}
) : (
pending.map((cl, i) => (
))
)}
>
) : (
<>
{mergeSelected === null && (
)}
{mergeSelected !== null && mergeSelected.length > 0 && (
{mergeSelected[0].canonical_name} will keep all aliases.
{mergeSelected.length > 1 && ` Tap "Merge ${mergeSelected.length}" to absorb the rest.`}
)}
{filtered.length === 0 && (
No people yet
)}
{relOrder.map(relKey => {
const group = relGroups[relKey];
if (!group || group.length === 0) return null;
const relInfo = REL_MAP[relKey];
return (
{relInfo.emoji} {relInfo.label} ({group.length})
{group.map(p => (
))}
);
})}
>
)}
);
}