Skip to content

Commit e18b7a2

Browse files
committed
feat: add On This Day section to dashboard showing ancestor anniversaries
Queries vital_event table for birth/death dates matching today's month and day, displays matching ancestors with photos, years ago, and places on the main dashboard. Parses date_original text (e.g. "12 Mar 1847") to extract month/day for matching.
1 parent b8f994d commit e18b7a2

7 files changed

Lines changed: 268 additions & 1 deletion

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Added
44

5+
- On This Day: dashboard section showing ancestors with birth/death anniversaries on today's date
56
- Tree auditor agent: BFS-walks family tree validating data integrity (impossible dates, parent age conflicts, placeholder names, unlinked providers)
67
- Audit persistence in SQLite with pause/resume/cancel support via cursor serialization
78
- Audit REST API with SSE event streaming, issue accept/reject/undo, bulk operations

client/src/components/Dashboard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
22
import { Link } from 'react-router-dom';
33
import { Trash2, Users, GitBranch, Search, Route, Loader2, Database, FlaskConical, Eye, EyeOff, RefreshCw, Calculator, Download, BarChart3 } from 'lucide-react';
44
import { CopyButton } from './ui/CopyButton';
5+
import { OnThisDay } from './dashboard/OnThisDay';
56
import toast from 'react-hot-toast';
67
import type { DatabaseInfo } from '@fsf/shared';
78
import { api } from '../services/api';
@@ -173,6 +174,9 @@ export function Dashboard() {
173174
)}
174175
</div>
175176

177+
{/* On This Day section */}
178+
<OnThisDay databases={visibleDatabases} />
179+
176180
{visibleDatabases.length === 0 ? (
177181
<div className="text-center py-8 text-app-text-muted">
178182
<p>No roots found.</p>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { useEffect, useState } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { Calendar, Cake, Cross } from 'lucide-react';
4+
import type { OnThisDayEvent, DatabaseInfo } from '@fsf/shared';
5+
import { api } from '../../services/api';
6+
7+
const MONTH_NAMES = [
8+
'January', 'February', 'March', 'April', 'May', 'June',
9+
'July', 'August', 'September', 'October', 'November', 'December',
10+
];
11+
12+
function formatOrdinal(day: number): string {
13+
const s = ['th', 'st', 'nd', 'rd'];
14+
const v = day % 100;
15+
return day + (s[(v - 20) % 10] || s[v] || s[0]);
16+
}
17+
18+
function yearsAgo(year: number | null): string {
19+
if (!year) return '';
20+
const current = new Date().getFullYear();
21+
const diff = current - year;
22+
if (diff <= 0) return '';
23+
return `${diff} years ago`;
24+
}
25+
26+
interface OnThisDayProps {
27+
databases: DatabaseInfo[];
28+
}
29+
30+
export function OnThisDay({ databases }: OnThisDayProps) {
31+
const [events, setEvents] = useState<(OnThisDayEvent & { dbId: string })[]>([]);
32+
const [loading, setLoading] = useState(true);
33+
34+
const today = new Date();
35+
const month = today.getMonth() + 1;
36+
const day = today.getDate();
37+
38+
useEffect(() => {
39+
if (databases.length === 0) {
40+
setLoading(false);
41+
return;
42+
}
43+
44+
Promise.all(
45+
databases.map(db =>
46+
api.getOnThisDay(db.id, month, day)
47+
.then(list => list.map(e => ({ ...e, dbId: db.id })))
48+
.catch(() => [] as (OnThisDayEvent & { dbId: string })[])
49+
)
50+
)
51+
.then(results => {
52+
// Flatten and deduplicate by personId+eventType across databases
53+
const seen = new Set<string>();
54+
const merged: (OnThisDayEvent & { dbId: string })[] = [];
55+
for (const list of results) {
56+
for (const evt of list) {
57+
const key = `${evt.personId}:${evt.eventType}`;
58+
if (!seen.has(key)) {
59+
seen.add(key);
60+
merged.push(evt);
61+
}
62+
}
63+
}
64+
// Sort: births first, then by year
65+
merged.sort((a, b) => {
66+
if (a.eventType !== b.eventType) return a.eventType === 'birth' ? -1 : 1;
67+
return (a.year ?? 0) - (b.year ?? 0);
68+
});
69+
setEvents(merged);
70+
})
71+
.finally(() => setLoading(false));
72+
}, [databases.length, month, day]);
73+
74+
if (loading || events.length === 0) return null;
75+
76+
return (
77+
<div className="bg-app-card border border-app-border rounded-lg p-4 mb-4">
78+
<h2 className="text-sm font-semibold text-app-text mb-3 flex items-center gap-2">
79+
<Calendar size={14} className="text-app-accent" />
80+
On This Day — {MONTH_NAMES[month - 1]} {formatOrdinal(day)}
81+
</h2>
82+
<div className="space-y-2 max-h-64 overflow-y-auto">
83+
{events.map(evt => (
84+
<Link
85+
key={`${evt.personId}-${evt.eventType}`}
86+
to={`/person/${evt.dbId}/${evt.personId}`}
87+
className="flex items-center gap-3 p-2 rounded hover:bg-app-accent/10 transition-colors group"
88+
>
89+
{/* Photo or icon */}
90+
{evt.hasPhoto ? (
91+
<img
92+
src={api.getPhotoUrl(evt.personId)}
93+
alt={evt.displayName}
94+
className="w-8 h-8 rounded-full object-cover border border-app-border flex-shrink-0"
95+
/>
96+
) : (
97+
<div className="w-8 h-8 rounded-full bg-app-border flex items-center justify-center flex-shrink-0">
98+
{evt.eventType === 'birth' ? (
99+
<Cake size={14} className="text-blue-400" />
100+
) : (
101+
<Cross size={14} className="text-app-text-muted" />
102+
)}
103+
</div>
104+
)}
105+
106+
{/* Details */}
107+
<div className="flex-1 min-w-0">
108+
<div className="flex items-center gap-2">
109+
<span className="text-sm font-medium text-app-text truncate group-hover:text-app-accent transition-colors">
110+
{evt.displayName}
111+
</span>
112+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
113+
evt.eventType === 'birth'
114+
? 'bg-blue-500/20 text-blue-400'
115+
: 'bg-app-text-muted/20 text-app-text-muted'
116+
}`}>
117+
{evt.eventType === 'birth' ? 'Born' : 'Died'}
118+
</span>
119+
</div>
120+
<div className="text-xs text-app-text-muted flex items-center gap-2">
121+
{evt.year && <span>{evt.year}</span>}
122+
{evt.year && <span className="text-app-text-subtle">({yearsAgo(evt.year)})</span>}
123+
{evt.place && <span className="truncate">{evt.place}</span>}
124+
</div>
125+
</div>
126+
</Link>
127+
))}
128+
</div>
129+
</div>
130+
);
131+
}

client/src/services/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type {
4343
AuditIssue,
4444
AuditChange,
4545
AuditSummary,
46+
OnThisDayEvent,
4647
} from '@fsf/shared';
4748

4849
export const BASE_URL = '/api';
@@ -89,6 +90,14 @@ export const api = {
8990
getTreeStats: (id: string) =>
9091
fetchJson<TreeStats>(`/databases/${id}/stats`),
9192

93+
getOnThisDay: (id: string, month?: number, day?: number) => {
94+
const params = new URLSearchParams();
95+
if (month !== undefined) params.set('month', String(month));
96+
if (day !== undefined) params.set('day', String(day));
97+
const qs = params.toString();
98+
return fetchJson<OnThisDayEvent[]>(`/databases/${id}/on-this-day${qs ? `?${qs}` : ''}`);
99+
},
100+
92101
deleteDatabase: (id: string) =>
93102
fetchJson<void>(`/databases/${id}`, { method: 'DELETE' }),
94103

server/src/routes/database.routes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ databaseRoutes.get('/:id/stats', async (req, res, next) => {
5656
if (result) res.json({ success: true, data: result });
5757
});
5858

59+
// GET /api/databases/:id/on-this-day - Ancestors with anniversaries on a given date
60+
databaseRoutes.get('/:id/on-this-day', (req, res) => {
61+
const month = req.query.month ? parseInt(req.query.month as string) : new Date().getMonth() + 1;
62+
const day = req.query.day ? parseInt(req.query.day as string) : new Date().getDate();
63+
64+
if (month < 1 || month > 12 || day < 1 || day > 31) {
65+
return res.status(400).json({ success: false, error: 'Invalid month or day' });
66+
}
67+
68+
const result = databaseService.getOnThisDay(req.params.id, month, day);
69+
res.json({ success: true, data: result });
70+
});
71+
5972
// DELETE /api/databases/:id - Delete database (root)
6073
databaseRoutes.delete('/:id', async (req, res, next) => {
6174
const ok = await databaseService.deleteDatabase(req.params.id).then(() => true).catch(next);

server/src/services/database.service.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'fs';
22
import path from 'path';
3-
import type { Database, DatabaseInfo, Person, PersonWithId } from '@fsf/shared';
3+
import type { Database, DatabaseInfo, Person, PersonWithId, OnThisDayEvent } from '@fsf/shared';
44
import { sqliteService } from '../db/sqlite.service.js';
55
import { idMappingService } from './id-mapping.service.js';
66
import { scraperService } from './scraper.service.js';
@@ -1346,4 +1346,101 @@ export const databaseService = {
13461346
occupations: occupationRows.map(r => ({ occupation: r.occupation, count: r.count })),
13471347
};
13481348
},
1349+
1350+
/**
1351+
* Get ancestors with birth/death anniversaries on a given month/day.
1352+
*/
1353+
getOnThisDay(rootId: string, month: number, day: number): OnThisDayEvent[] {
1354+
if (!useSqlite) return [];
1355+
1356+
const canonical = idMappingService.resolveId(rootId, 'familysearch') || rootId;
1357+
const rootInfo = sqliteService.queryOne<{ db_id: string }>(
1358+
'SELECT db_id FROM database_info WHERE db_id = @canonical',
1359+
{ canonical }
1360+
);
1361+
if (!rootInfo) return [];
1362+
1363+
const dbId = canonical;
1364+
1365+
// Query all birth/death events with date_original text
1366+
const rows = sqliteService.queryAll<{
1367+
person_id: string;
1368+
display_name: string;
1369+
gender: string | null;
1370+
event_type: string;
1371+
date_original: string;
1372+
date_year: number | null;
1373+
place: string | null;
1374+
}>(
1375+
`SELECT ve.person_id, p.display_name, p.gender,
1376+
ve.event_type, ve.date_original, ve.date_year, ve.place
1377+
FROM vital_event ve
1378+
JOIN database_membership dm ON ve.person_id = dm.person_id AND dm.db_id = @dbId
1379+
JOIN person p ON ve.person_id = p.person_id
1380+
WHERE ve.event_type IN ('birth', 'death')
1381+
AND ve.date_original IS NOT NULL`,
1382+
{ dbId }
1383+
);
1384+
1385+
// Check which persons have photos
1386+
const photoPersons = new Set(
1387+
sqliteService.queryAll<{ person_id: string }>(
1388+
`SELECT DISTINCT m.person_id
1389+
FROM media m
1390+
JOIN database_membership dm ON m.person_id = dm.person_id AND dm.db_id = @dbId`,
1391+
{ dbId }
1392+
).map(r => r.person_id)
1393+
);
1394+
1395+
// Parse dates and filter for matching month/day
1396+
const results: OnThisDayEvent[] = [];
1397+
const seen = new Set<string>(); // Dedupe by personId+eventType
1398+
1399+
for (const row of rows) {
1400+
const parsed = parseDateMonthDay(row.date_original);
1401+
if (!parsed || parsed.month !== month || parsed.day !== day) continue;
1402+
1403+
const key = `${row.person_id}:${row.event_type}`;
1404+
if (seen.has(key)) continue;
1405+
seen.add(key);
1406+
1407+
results.push({
1408+
personId: row.person_id,
1409+
displayName: row.display_name,
1410+
gender: (row.gender as OnThisDayEvent['gender']) ?? undefined,
1411+
eventType: row.event_type as 'birth' | 'death',
1412+
dateOriginal: row.date_original,
1413+
year: row.date_year,
1414+
place: row.place ?? undefined,
1415+
hasPhoto: photoPersons.has(row.person_id),
1416+
});
1417+
}
1418+
1419+
// Sort: births first, then by year ascending
1420+
results.sort((a, b) => {
1421+
if (a.eventType !== b.eventType) return a.eventType === 'birth' ? -1 : 1;
1422+
return (a.year ?? 0) - (b.year ?? 0);
1423+
});
1424+
1425+
return results;
1426+
},
1427+
};
1428+
1429+
const MONTH_NAMES: Record<string, number> = {
1430+
january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
1431+
july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
1432+
jan: 1, feb: 2, mar: 3, apr: 4, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
13491433
};
1434+
1435+
/** Extract month and day from a date string like "12 March 1847" or "12 Mar 1847" */
1436+
function parseDateMonthDay(dateOriginal: string): { month: number; day: number } | null {
1437+
const match = dateOriginal.match(/(\d{1,2})\s+([A-Za-z]+)\s+/);
1438+
if (!match) return null;
1439+
1440+
const day = parseInt(match[1]);
1441+
const monthStr = match[2].toLowerCase();
1442+
const month = MONTH_NAMES[monthStr];
1443+
if (!month || day < 1 || day > 31) return null;
1444+
1445+
return { month, day };
1446+
}

shared/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,18 @@ export interface TreeStats {
523523
occupations: { occupation: string; count: number }[];
524524
}
525525

526+
// On This Day - ancestors with anniversaries on a given date
527+
export interface OnThisDayEvent {
528+
personId: string;
529+
displayName: string;
530+
gender?: 'male' | 'female' | 'unknown';
531+
eventType: 'birth' | 'death';
532+
dateOriginal: string;
533+
year: number | null;
534+
place?: string;
535+
hasPhoto: boolean;
536+
}
537+
526538
// Person with ID included
527539
export interface PersonWithId extends Person {
528540
id: string; // Canonical ULID for URL routing

0 commit comments

Comments
 (0)