Skip to content

Commit de482fe

Browse files
committed
feat: add multi-platform data comparison and compact UI redesign
- Add multi-platform comparison feature to compare data across FamilySearch, Ancestry, WikiTree, and Wikipedia - Create unified provider cache structure at data/provider-cache/ - Update Ancestry scraper for 2024/2025 DOM structure - Redesign PersonDetail page with compact layout: - Side-by-side Vital Events and Family cards - Inline tags for Also Known As and Occupations - Unified Platforms & Data section - Add LinkPlatformDialog modal for linking profiles - Add new API endpoints for comparison and provider refresh - Add editable UI components (EditableDate, EditableField, EditableList) - Add VitalEventCard component for birth/death/burial display
1 parent 1ca8c8b commit de482fe

24 files changed

Lines changed: 5201 additions & 966 deletions

.changelog/v0.5.x.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Release v0.5.x - Multi-Platform Comparison & UI Redesign
2+
3+
Released: 2026-01-25
4+
5+
## Overview
6+
7+
This release adds multi-platform data comparison capabilities, allowing you to compare person data across FamilySearch, Ancestry, WikiTree, and Wikipedia. It also includes a major UI redesign for the person detail page with a more compact, user-friendly layout.
8+
9+
## New Features
10+
11+
### Multi-Platform Data Comparison (v0.5.0)
12+
- Compare person data across multiple genealogy providers side-by-side
13+
- Field-by-field comparison with visual indicators: ✓ match, ⚠ different, — missing
14+
- Unified provider cache structure at `data/provider-cache/{provider}/{externalId}.json`
15+
- Download/refresh data from providers directly from the UI
16+
- Collapsible field comparison panel showing divergences
17+
18+
### Compact PersonDetail Layout (v0.5.0)
19+
- Redesigned person detail page with more efficient use of screen space
20+
- Side-by-side Vital Events (birth/death/burial) and Family cards at top
21+
- Inline tags for Also Known As and Occupations
22+
- Unified Platforms & Data section combining provider links and comparison
23+
- Compact bio section
24+
25+
### Link Platform Dialog (v0.5.0)
26+
- New modal dialog for linking Wikipedia, Ancestry, and WikiTree profiles
27+
- Platform-specific styling and placeholder text
28+
- Keyboard support (Escape to close, Enter to submit)
29+
- Auto-focus on URL input when opened
30+
31+
### Ancestry Scraper Updates (v0.5.0)
32+
- Updated selectors for 2024/2025 Ancestry DOM structure
33+
- Extracts name from `.userCardTitle`
34+
- Extracts birth/death from `.userCardEventDetail` spans
35+
- Extracts parents from Relationships section
36+
- Extracts profile photo URL
37+
- Proper handling of "Unknown" and "Living" status values
38+
39+
## New Files
40+
41+
### Client Components
42+
- `client/src/components/person/UnifiedPlatformSection.tsx` - Combined provider links and comparison table
43+
- `client/src/components/person/LinkPlatformDialog.tsx` - Modal dialog for linking platforms
44+
- `client/src/components/person/VitalEventCard.tsx` - Compact vital event display with editing
45+
- `client/src/components/ui/EditableDate.tsx` - Inline date editor with genealogy format validation
46+
- `client/src/components/ui/EditableField.tsx` - Inline text field editor
47+
- `client/src/components/ui/EditableList.tsx` - Inline list editor
48+
49+
### Server Services
50+
- `server/src/services/multi-platform-comparison.service.ts` - Provider data comparison logic
51+
- `server/src/services/familysearch-refresh.service.ts` - FamilySearch data refresh
52+
- `server/src/services/familysearch-upload.service.ts` - Upload changes to FamilySearch
53+
- `server/src/services/local-override.service.ts` - Local override management
54+
55+
### Scripts
56+
- `scripts/migrate-provider-cache.ts` - Migration script for unified cache structure
57+
58+
## API Endpoints
59+
60+
### New Endpoints
61+
- `GET /api/sync/:dbId/:personId/multi-platform-compare` - Get full multi-platform comparison
62+
- `POST /api/sync/:dbId/:personId/refresh-provider/:provider` - Refresh data from specific provider
63+
64+
## Technical Details
65+
66+
### Provider Cache Structure
67+
Provider data is now cached in a unified structure:
68+
```
69+
data/provider-cache/
70+
familysearch/{fsId}.json
71+
ancestry/{externalId}.json
72+
wikitree/{externalId}.json
73+
```
74+
75+
### Comparison Algorithm
76+
- FamilySearch serves as the baseline (primary source)
77+
- Field comparison uses fuzzy matching for places (contains check)
78+
- Alternate names are sorted alphabetically before comparison to avoid false differences
79+
- Missing values are distinguished from empty values
80+
81+
## Full Changelog
82+
83+
**Full Diff**: https://github.com/atomantic/SparseTree/compare/v0.4.16...v0.5.0

client/src/components/Dashboard.tsx

Lines changed: 77 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function Dashboard() {
9191
const handleRefreshEvent = useCallback((data: { dbId: string; status: string; personCount?: number; data?: DatabaseInfo; message?: string }) => {
9292
if (data.status === 'complete' && data.data) {
9393
setDatabases(prev => prev.map(d => d.id === data.dbId ? data.data! : d));
94-
toast.success(`Updated count: ${data.personCount?.toLocaleString()} people`);
94+
toast.success(`Updated count: ${data.personCount?.toLocaleString()} parents`);
9595
setRefreshingId(null);
9696
} else if (data.status === 'error') {
9797
toast.error(`Failed to refresh: ${data.message || 'Unknown error'}`);
@@ -213,73 +213,98 @@ export function Dashboard() {
213213
key={db.id}
214214
className="bg-app-card rounded-lg border border-app-border p-4 hover:border-app-accent/50 transition-colors"
215215
>
216-
{/* Badges row */}
217-
<div className="flex items-center gap-2 mb-2">
218-
{db.isSample && (
216+
{/* Sample badge */}
217+
{db.isSample && (
218+
<div className="mb-2">
219219
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-amber-600/20 text-amber-600 dark:text-amber-400">
220220
<FlaskConical size={10} />
221221
Sample
222222
</span>
223-
)}
224-
{db.sourceProvider && (
225-
(() => {
226-
const colors = platformColors[db.sourceProvider] || { bg: 'bg-app-text-muted/20', text: 'text-app-text-muted' };
227-
return (
228-
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${colors.bg} ${colors.text}`}>
229-
<Database size={10} />
230-
{db.sourceProvider}
231-
</span>
232-
);
233-
})()
234-
)}
235-
</div>
223+
</div>
224+
)}
236225

237-
{/* Header with name and actions */}
238-
<div className="flex items-start justify-between mb-2">
239-
<div className="flex-1 min-w-0">
240-
<h2 className="font-semibold text-lg text-app-text truncate">
241-
{db.rootName || 'Unknown Person'}
242-
</h2>
243-
<div className="flex items-center gap-1">
244-
<span className="text-xs text-app-text-muted font-mono">{db.rootExternalId || db.rootId}</span>
245-
<CopyButton text={db.rootExternalId || db.rootId} size={12} />
246-
{db.rootExternalId && (
247-
<Link
248-
to={`/indexer?rootId=${db.rootExternalId}`}
249-
className="p-1 text-app-text-muted hover:text-app-accent hover:bg-app-accent/10 rounded transition-colors"
250-
title="Update from FamilySearch"
251-
>
252-
<Download size={12} />
253-
</Link>
254-
)}
226+
{/* Header with photo, name and actions */}
227+
<div className="flex items-start gap-3 mb-2">
228+
{/* Photo */}
229+
{db.hasPhoto && (
230+
<Link to={`/person/${db.id}/${db.rootId}`} className="flex-shrink-0">
231+
<img
232+
src={api.getPhotoUrl(db.rootId)}
233+
alt={db.rootName || 'Root person'}
234+
className="w-16 h-16 rounded-lg object-cover border border-app-border hover:border-app-accent transition-colors"
235+
/>
236+
</Link>
237+
)}
238+
{/* Name and actions */}
239+
<div className="flex-1 min-w-0 flex items-start justify-between">
240+
<div className="flex-1 min-w-0">
241+
<h2 className="font-semibold text-lg text-app-text truncate">
242+
{db.rootName || 'Unknown Person'}
243+
</h2>
255244
</div>
256-
</div>
257-
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
258-
<button
259-
onClick={() => handleRefresh(db)}
260-
disabled={refreshingId === db.id}
261-
className="p-1.5 text-app-text-muted hover:text-app-accent hover:bg-app-accent/10 rounded transition-colors disabled:opacity-50"
262-
title="Refresh ancestor count"
263-
>
264-
<RefreshCw size={16} className={refreshingId === db.id ? 'animate-spin' : ''} />
265-
</button>
266-
{!db.isSample && (
245+
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
267246
<button
268-
onClick={() => setDeleteTarget(db)}
269-
className="p-1.5 text-app-text-muted hover:text-app-error hover:bg-app-error-subtle rounded transition-colors"
270-
title="Remove root"
247+
onClick={() => handleRefresh(db)}
248+
disabled={refreshingId === db.id}
249+
className="p-1.5 text-app-text-muted hover:text-app-accent hover:bg-app-accent/10 rounded transition-colors disabled:opacity-50"
250+
title="Refresh ancestor count"
271251
>
272-
<Trash2 size={16} />
252+
<RefreshCw size={16} className={refreshingId === db.id ? 'animate-spin' : ''} />
273253
</button>
274-
)}
254+
{!db.isSample && (
255+
<button
256+
onClick={() => setDeleteTarget(db)}
257+
className="p-1.5 text-app-text-muted hover:text-app-error hover:bg-app-error-subtle rounded transition-colors"
258+
title="Remove root"
259+
>
260+
<Trash2 size={16} />
261+
</button>
262+
)}
263+
</div>
275264
</div>
276265
</div>
277266

267+
{/* Platform IDs */}
268+
<div className="mb-3 space-y-1">
269+
{/* SparseTree canonical ID */}
270+
<div className="flex items-center gap-1.5 text-xs">
271+
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-app-accent/10 text-app-accent font-medium min-w-[70px]">
272+
<Database size={10} />
273+
sparsetree
274+
</span>
275+
<span className="font-mono text-app-text-muted truncate">{db.rootId}</span>
276+
<CopyButton text={db.rootId} size={10} />
277+
</div>
278+
{/* External platform IDs */}
279+
{db.externalIds && Object.entries(db.externalIds).map(([platform, extId]) => {
280+
const colors = platformColors[platform] || { bg: 'bg-app-text-muted/20', text: 'text-app-text-muted' };
281+
return (
282+
<div key={platform} className="flex items-center gap-1.5 text-xs">
283+
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded font-medium min-w-[70px] ${colors.bg} ${colors.text}`}>
284+
<Database size={10} />
285+
{platform}
286+
</span>
287+
<span className="font-mono text-app-text-muted truncate">{extId}</span>
288+
<CopyButton text={extId} size={10} />
289+
{platform === 'familysearch' && (
290+
<Link
291+
to={`/indexer?rootId=${extId}`}
292+
className="p-0.5 text-app-text-muted hover:text-app-accent hover:bg-app-accent/10 rounded transition-colors"
293+
title="Update from FamilySearch"
294+
>
295+
<Download size={10} />
296+
</Link>
297+
)}
298+
</div>
299+
);
300+
})}
301+
</div>
302+
278303
{/* Stats */}
279304
<div className="flex items-center gap-4 text-sm text-app-text-muted mb-4">
280305
<span className="flex items-center gap-1">
281306
<Users size={14} />
282-
{db.personCount.toLocaleString()} people
307+
{db.personCount.toLocaleString()} parents
283308
</span>
284309
{db.maxGenerations ? (
285310
<span className="flex items-center gap-1">
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useState, useEffect, useRef } from 'react';
2+
import { X, Loader2, Link2 } from 'lucide-react';
3+
4+
type Platform = 'wikipedia' | 'ancestry' | 'wikitree';
5+
6+
interface LinkPlatformDialogProps {
7+
platform: Platform | null;
8+
onClose: () => void;
9+
onLink: (platform: Platform, url: string) => Promise<void>;
10+
loading?: boolean;
11+
}
12+
13+
const PLATFORM_CONFIG: Record<Platform, {
14+
title: string;
15+
placeholder: string;
16+
hint: string;
17+
color: string;
18+
bgColor: string;
19+
}> = {
20+
wikipedia: {
21+
title: 'Link Wikipedia Article',
22+
placeholder: 'https://en.wikipedia.org/wiki/Person_Name',
23+
hint: 'Paste a Wikipedia URL to import photo and description for this person.',
24+
color: 'text-blue-600 dark:text-blue-400',
25+
bgColor: 'bg-blue-600/10',
26+
},
27+
ancestry: {
28+
title: 'Link Ancestry Profile',
29+
placeholder: 'https://www.ancestry.com/family-tree/person/tree/.../person/...',
30+
hint: 'Paste an Ancestry profile URL to link this person to their Ancestry tree.',
31+
color: 'text-emerald-600 dark:text-emerald-400',
32+
bgColor: 'bg-emerald-600/10',
33+
},
34+
wikitree: {
35+
title: 'Link WikiTree Profile',
36+
placeholder: 'https://www.wikitree.com/wiki/LastName-123',
37+
hint: 'Paste a WikiTree URL to link this person to their WikiTree profile.',
38+
color: 'text-purple-600 dark:text-purple-400',
39+
bgColor: 'bg-purple-600/10',
40+
},
41+
};
42+
43+
export function LinkPlatformDialog({ platform, onClose, onLink, loading = false }: LinkPlatformDialogProps) {
44+
const [url, setUrl] = useState('');
45+
const inputRef = useRef<HTMLInputElement>(null);
46+
47+
useEffect(() => {
48+
if (platform) {
49+
setUrl('');
50+
// Focus input when dialog opens
51+
setTimeout(() => inputRef.current?.focus(), 100);
52+
}
53+
}, [platform]);
54+
55+
if (!platform) return null;
56+
57+
const config = PLATFORM_CONFIG[platform];
58+
59+
const handleSubmit = async (e: React.FormEvent) => {
60+
e.preventDefault();
61+
if (!url.trim()) return;
62+
await onLink(platform, url.trim());
63+
};
64+
65+
const handleKeyDown = (e: React.KeyboardEvent) => {
66+
if (e.key === 'Escape') {
67+
onClose();
68+
}
69+
};
70+
71+
return (
72+
<div
73+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
74+
onClick={(e) => e.target === e.currentTarget && onClose()}
75+
onKeyDown={handleKeyDown}
76+
>
77+
<div className="bg-app-card rounded-lg border border-app-border shadow-xl max-w-md w-full mx-4">
78+
{/* Header */}
79+
<div className="flex items-center justify-between px-4 py-3 border-b border-app-border">
80+
<div className="flex items-center gap-2">
81+
<Link2 size={16} className={config.color} />
82+
<h3 className="font-semibold text-app-text">{config.title}</h3>
83+
</div>
84+
<button
85+
onClick={onClose}
86+
className="p-1 text-app-text-muted hover:text-app-text hover:bg-app-hover rounded transition-colors"
87+
>
88+
<X size={18} />
89+
</button>
90+
</div>
91+
92+
{/* Content */}
93+
<form onSubmit={handleSubmit} className="p-4">
94+
<div className="space-y-3">
95+
<input
96+
ref={inputRef}
97+
type="url"
98+
value={url}
99+
onChange={(e) => setUrl(e.target.value)}
100+
placeholder={config.placeholder}
101+
className="w-full px-3 py-2 bg-app-bg border border-app-border rounded text-app-text text-sm focus:outline-none focus:ring-2 focus:ring-app-accent"
102+
disabled={loading}
103+
/>
104+
<p className="text-xs text-app-text-subtle">{config.hint}</p>
105+
</div>
106+
107+
<div className="flex justify-end gap-2 mt-4">
108+
<button
109+
type="button"
110+
onClick={onClose}
111+
disabled={loading}
112+
className="px-3 py-1.5 text-sm text-app-text-secondary hover:bg-app-hover rounded transition-colors disabled:opacity-50"
113+
>
114+
Cancel
115+
</button>
116+
<button
117+
type="submit"
118+
disabled={loading || !url.trim()}
119+
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded ${config.bgColor} ${config.color} hover:opacity-80 disabled:opacity-50 transition-colors`}
120+
>
121+
{loading ? (
122+
<>
123+
<Loader2 size={14} className="animate-spin" />
124+
Linking...
125+
</>
126+
) : (
127+
<>
128+
<Link2 size={14} />
129+
Link
130+
</>
131+
)}
132+
</button>
133+
</div>
134+
</form>
135+
</div>
136+
</div>
137+
);
138+
}

0 commit comments

Comments
 (0)