From 6d2522252cc2a4b455b502546a778f9609bfd09c Mon Sep 17 00:00:00 2001 From: Peter Milanese Date: Mon, 4 May 2026 14:32:55 -0400 Subject: [PATCH 1/3] feat: add person connections for non-family relationships Adds a formal mechanism to track non-family relationships between people (friend, colleague, neighbor, employer/employee, godparent/ godchild, other) with optional notes and year range. - Migration v40: person_connections table with canonical ordering constraint (person_id_1 < person_id_2) to prevent duplicate pairs - GraphQL schema: PersonConnection type, PersonConnectionInput, connections field on Person, add/update/remove mutations - DataLoader: batchConnections fans rows to both participants - PersonConnection.person resolver uses _viewerId to return the other person in the connection - PersonConnectionsCard UI component with person search, type select, notes and year range fields Co-Authored-By: Claude Sonnet 4.6 --- components/PersonPageClient.tsx | 18 + components/person/PersonConnectionsCard.tsx | 337 ++++++++++++++++++ lib/graphql/dataloaders.ts | 32 +- lib/graphql/queries/index.ts | 3 + lib/graphql/queries/person.ts | 59 +++ lib/graphql/resolvers/index.ts | 4 +- .../resolvers/person/field-resolvers.ts | 6 +- lib/graphql/resolvers/person_connection.ts | 147 ++++++++ lib/graphql/schema/mutations.ts | 5 + lib/graphql/schema/types/person.ts | 28 ++ lib/migrations/index.ts | 2 + lib/migrations/v40-person-connections.ts | 58 +++ lib/types.ts | 15 + 13 files changed, 711 insertions(+), 3 deletions(-) create mode 100644 components/person/PersonConnectionsCard.tsx create mode 100644 lib/graphql/resolvers/person_connection.ts create mode 100644 lib/migrations/v40-person-connections.ts diff --git a/components/PersonPageClient.tsx b/components/PersonPageClient.tsx index 111675e3..b68bc600 100644 --- a/components/PersonPageClient.tsx +++ b/components/PersonPageClient.tsx @@ -13,6 +13,7 @@ import PersonFormModal from '@/components/PersonFormModal'; import CoatOfArmsDisplay from '@/components/person/CoatOfArmsDisplay'; import NotableEditorModal from '@/components/person/NotableEditorModal'; import { PendingFindingsIndicator } from '@/components/person/PendingFindingsIndicator'; +import PersonConnectionsCard from '@/components/person/PersonConnectionsCard'; import PersonFactsCard from '@/components/person/PersonFactsCard'; import PersonHeroCard from '@/components/person/PersonHeroCard'; import PersonRelativesList from '@/components/person/PersonRelativesList'; @@ -34,6 +35,7 @@ import type { LifeEvent, Media, Person, + PersonConnection, Source, } from '@/lib/types'; @@ -53,6 +55,15 @@ interface PersonData { facts: Fact[]; sources: Source[]; alternateNames: AlternateName[]; + connections: (PersonConnection & { + person: { + id: string; + name_full: string; + birth_year?: number | null; + death_year?: number | null; + sex?: string | null; + }; + })[]; media: Media[]; }) | null; @@ -242,6 +253,13 @@ export default function PersonPageClient({ personId }: Props) { canEdit={canEdit} /> + {/* Non-family connections */} + + {/* Coat of Arms */} diff --git a/components/person/PersonConnectionsCard.tsx b/components/person/PersonConnectionsCard.tsx new file mode 100644 index 00000000..d6a6c40c --- /dev/null +++ b/components/person/PersonConnectionsCard.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { useMutation } from '@apollo/client/react'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import PersonSearchSelect from '@/components/PersonSearchSelect'; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableRow, +} from '@/components/ui'; +import { + ADD_PERSON_CONNECTION, + GET_PERSON, + REMOVE_PERSON_CONNECTION, + UPDATE_PERSON_CONNECTION, +} from '@/lib/graphql/queries'; +import type { PersonConnection } from '@/lib/types'; + +const CONNECTION_TYPES = [ + { value: 'friend', label: 'Friend' }, + { value: 'colleague', label: 'Colleague' }, + { value: 'neighbor', label: 'Neighbor' }, + { value: 'employer', label: 'Employer' }, + { value: 'employee', label: 'Employee' }, + { value: 'godparent', label: 'Godparent' }, + { value: 'godchild', label: 'Godchild' }, + { value: 'other', label: 'Other' }, +]; + +interface ConnectedPerson { + id: string; + name_full: string; + birth_year?: number | null; + death_year?: number | null; +} + +type ConnectionWithPerson = PersonConnection & { person: ConnectedPerson }; + +interface PersonConnectionsCardProps { + personId: string; + connections: ConnectionWithPerson[]; + canEdit: boolean; +} + +const emptyForm = { + other_person_id: '', + connection_type: 'friend', + notes: '', + start_year: '', + end_year: '', +}; + +export default function PersonConnectionsCard({ + personId, + connections, + canEdit, +}: PersonConnectionsCardProps) { + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formData, setFormData] = useState(emptyForm); + + const refetchQueries = [{ query: GET_PERSON, variables: { id: personId } }]; + + const [addConnection, { loading: adding }] = useMutation( + ADD_PERSON_CONNECTION, + { + refetchQueries, + onCompleted: () => { + toast.success('Connection added'); + resetForm(); + }, + onError: (err) => toast.error('Error', { description: err.message }), + }, + ); + + const [updateConnection, { loading: updating }] = useMutation( + UPDATE_PERSON_CONNECTION, + { + refetchQueries, + onCompleted: () => { + toast.success('Connection updated'); + resetForm(); + }, + onError: (err) => toast.error('Error', { description: err.message }), + }, + ); + + const [removeConnection] = useMutation(REMOVE_PERSON_CONNECTION, { + refetchQueries, + onCompleted: () => toast.success('Connection removed'), + onError: (err) => toast.error('Error', { description: err.message }), + }); + + const resetForm = () => { + setFormData(emptyForm); + setEditingId(null); + setShowForm(false); + }; + + const handleEdit = (conn: ConnectionWithPerson) => { + setFormData({ + other_person_id: conn.person.id, + connection_type: conn.connection_type, + notes: conn.notes || '', + start_year: conn.start_year?.toString() || '', + end_year: conn.end_year?.toString() || '', + }); + setEditingId(conn.id.toString()); + setShowForm(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const input = { + other_person_id: formData.other_person_id || undefined, + connection_type: formData.connection_type, + notes: formData.notes || null, + start_year: formData.start_year ? parseInt(formData.start_year) : null, + end_year: formData.end_year ? parseInt(formData.end_year) : null, + }; + if (editingId) { + await updateConnection({ variables: { id: editingId, input } }); + } else { + if (!formData.other_person_id) { + toast.error('Please select a person'); + return; + } + await addConnection({ variables: { personId, input } }); + } + }; + + const handleDelete = async (id: number) => { + if (confirm('Remove this connection?')) { + await removeConnection({ variables: { id } }); + } + }; + + if (connections.length === 0 && !canEdit) return null; + + return ( + + +
+ Connections + {canEdit && !showForm && ( + + )} +
+
+ + {showForm && ( +
+ {!editingId && ( +
+ + + setFormData((p) => ({ ...p, other_person_id: id || '' })) + } + placeholder="Search for a person..." + /> +
+ )} +
+
+ + +
+
+ + + setFormData((p) => ({ ...p, start_year: e.target.value })) + } + placeholder="e.g. 1950" + /> +
+
+ + + setFormData((p) => ({ ...p, end_year: e.target.value })) + } + placeholder="e.g. 1970" + /> +
+
+
+ + + setFormData((p) => ({ ...p, notes: e.target.value })) + } + placeholder="Optional notes..." + /> +
+
+ + +
+
+ )} + + {connections.length > 0 ? ( + + + {connections.map((conn) => ( + + + + {CONNECTION_TYPES.find( + (t) => t.value === conn.connection_type, + )?.label ?? conn.connection_type} + + + + + {conn.person.name_full} + + {(conn.person.birth_year || conn.person.death_year) && ( + + ({conn.person.birth_year ?? '?'} + {conn.person.death_year + ? ` – ${conn.person.death_year}` + : ''} + ) + + )} + {conn.notes && ( +

+ {conn.notes} +

+ )} + {(conn.start_year || conn.end_year) && ( +

+ {conn.start_year ?? '?'} + {conn.end_year ? ` – ${conn.end_year}` : '+'} +

+ )} +
+ {canEdit && ( + +
+ + +
+
+ )} +
+ ))} +
+
+ ) : ( +

+ No connections recorded yet. +

+ )} +
+
+ ); +} diff --git a/lib/graphql/dataloaders.ts b/lib/graphql/dataloaders.ts index 3fd22adb..26df8139 100644 --- a/lib/graphql/dataloaders.ts +++ b/lib/graphql/dataloaders.ts @@ -1,6 +1,14 @@ import DataLoader from 'dataloader'; import { pool } from '../pool'; -import type { Fact, Family, LifeEvent, Media, Person, Source } from '../types'; +import type { + Fact, + Family, + LifeEvent, + Media, + Person, + PersonConnection, + Source, +} from '../types'; // ============================================ // BATCH LOADERS - Single SQL query per batch @@ -180,6 +188,27 @@ async function batchMedia(personIds: readonly string[]): Promise { return personIds.map((id) => map.get(id) || []); } +// Batch load person connections by person IDs (fans rows to both participants) +async function batchConnections( + personIds: readonly string[], +): Promise { + if (!personIds.length) return []; + const { rows } = await pool.query( + `SELECT * FROM person_connections + WHERE person_id_1 = ANY($1) OR person_id_2 = ANY($1) + ORDER BY created_at DESC`, + [personIds as string[]], + ); + const map = new Map( + personIds.map((id) => [id, []]), + ); + for (const row of rows) { + if (map.has(row.person_id_1)) map.get(row.person_id_1)?.push(row); + if (map.has(row.person_id_2)) map.get(row.person_id_2)?.push(row); + } + return personIds.map((id) => map.get(id) || []); +} + // ============================================ // LOADER FACTORY - Fresh loaders per request // ============================================ @@ -201,6 +230,7 @@ export function createLoaders() { factsLoader: new DataLoader(batchFacts, { cache: true }), sourcesLoader: new DataLoader(batchSources, { cache: true }), mediaLoader: new DataLoader(batchMedia, { cache: true }), + connectionsLoader: new DataLoader(batchConnections, { cache: true }), }; } diff --git a/lib/graphql/queries/index.ts b/lib/graphql/queries/index.ts index a12ef741..b5c161f1 100644 --- a/lib/graphql/queries/index.ts +++ b/lib/graphql/queries/index.ts @@ -99,6 +99,7 @@ export { ADD_ALTERNATE_NAME, ADD_FACT, ADD_LIFE_EVENT, + ADD_PERSON_CONNECTION, CREATE_PERSON, DELETE_ALTERNATE_NAME, DELETE_FACT, @@ -111,12 +112,14 @@ export { GET_PERSON_SOURCES, GET_RECENT_PEOPLE, GET_SEARCH_SUGGESTIONS, + REMOVE_PERSON_CONNECTION, SEARCH_PEOPLE, UPDATE_ALTERNATE_NAME, UPDATE_FACT, UPDATE_LIFE_EVENT, UPDATE_NOTABLE_STATUS, UPDATE_PERSON, + UPDATE_PERSON_CONNECTION, } from './person'; // Settings and GEDCOM export { diff --git a/lib/graphql/queries/person.ts b/lib/graphql/queries/person.ts index d30af4b5..32f1364f 100644 --- a/lib/graphql/queries/person.ts +++ b/lib/graphql/queries/person.ts @@ -104,6 +104,22 @@ export const GET_PERSON = gql` is_primary notes } + connections { + id + person_id_1 + person_id_2 + connection_type + notes + start_year + end_year + person { + id + name_full + birth_year + death_year + sex + } + } media { ...MediaFields } @@ -354,3 +370,46 @@ export const DELETE_ALTERNATE_NAME = gql` deleteAlternateName(id: $id) } `; + +// ===================================================== +// PERSON CONNECTION MUTATIONS +// ===================================================== + +export const ADD_PERSON_CONNECTION = gql` + mutation AddPersonConnection($personId: ID!, $input: PersonConnectionInput!) { + addPersonConnection(personId: $personId, input: $input) { + id + person_id_1 + person_id_2 + connection_type + notes + start_year + end_year + person { + id + name_full + birth_year + death_year + sex + } + } + } +`; + +export const UPDATE_PERSON_CONNECTION = gql` + mutation UpdatePersonConnection($id: ID!, $input: PersonConnectionInput!) { + updatePersonConnection(id: $id, input: $input) { + id + connection_type + notes + start_year + end_year + } + } +`; + +export const REMOVE_PERSON_CONNECTION = gql` + mutation RemovePersonConnection($id: ID!) { + removePersonConnection(id: $id) + } +`; diff --git a/lib/graphql/resolvers/index.ts b/lib/graphql/resolvers/index.ts index 7893b4b5..37076f50 100644 --- a/lib/graphql/resolvers/index.ts +++ b/lib/graphql/resolvers/index.ts @@ -8,14 +8,15 @@ import { merge } from 'lodash'; import { activityResolvers } from './activity'; import { adminResolvers } from './admin'; import { alternateNameResolvers } from './alternate_name'; +import { personConnectionResolvers } from './person_connection'; import { commentResolvers } from './comment'; import { crestResolvers } from './crest'; import { cyclesResolvers } from './cycles'; import { dashboardResolvers } from './dashboard'; import { duplicatesResolvers } from './duplicates'; import { factResolvers } from './fact'; -import { familyResearchNotesResolvers } from './family-research-notes'; import { familyResolvers } from './family'; +import { familyResearchNotesResolvers } from './family-research-notes'; import { gedcomResolvers } from './gedcom'; import { life_eventResolvers } from './life_event'; import { mapResolvers } from './map'; @@ -108,4 +109,5 @@ export const resolvers = merge( stagedFindingsResolvers, userResolvers, alternateNameResolvers, + personConnectionResolvers, ); diff --git a/lib/graphql/resolvers/person/field-resolvers.ts b/lib/graphql/resolvers/person/field-resolvers.ts index 6ca541b6..bb5b583d 100644 --- a/lib/graphql/resolvers/person/field-resolvers.ts +++ b/lib/graphql/resolvers/person/field-resolvers.ts @@ -25,7 +25,6 @@ export const personFieldResolvers = { : []; }, - father: async (person: { id: string }, _: unknown, ctx: Context) => { const families = await ctx.loaders.familiesAsChildLoader.load(person.id); if (families.length === 0) return null; @@ -209,6 +208,11 @@ export const personFieldResolvers = { return rows; }, + connections: async (person: { id: string }, _: unknown, ctx: Context) => { + const rows = await ctx.loaders.connectionsLoader.load(person.id); + return rows.map((row) => ({ ...row, _viewerId: person.id })); + }, + // Notable relatives connected through ancestry (cached for performance) notableRelatives: async (person: { id: string }) => { const cacheKey = `notable_relatives:${person.id}`; diff --git a/lib/graphql/resolvers/person_connection.ts b/lib/graphql/resolvers/person_connection.ts new file mode 100644 index 00000000..3c3247d4 --- /dev/null +++ b/lib/graphql/resolvers/person_connection.ts @@ -0,0 +1,147 @@ +/** + * GraphQL resolvers for person connection (non-family relationship) operations. + * @module lib/graphql/resolvers/person_connection + */ +import { pool } from '../../pool'; +import { logAudit } from '../../users'; +import { type Context, requireAuth } from './helpers'; + +interface PersonConnectionInput { + other_person_id?: string; + connection_type: string; + notes?: string; + start_year?: number; + end_year?: number; +} + +export const personConnectionResolvers = { + PersonConnection: { + person: async ( + row: { + person_id_1: string; + person_id_2: string; + _viewerId?: string; + }, + _: unknown, + ctx: Context, + ) => { + const otherId = + row._viewerId === row.person_id_1 ? row.person_id_2 : row.person_id_1; + return ctx.loaders.personLoader.load(otherId); + }, + }, + + Mutation: { + addPersonConnection: async ( + _: unknown, + { + personId, + input, + }: { + personId: string; + input: PersonConnectionInput; + }, + context: Context, + ) => { + const user = requireAuth(context, 'editor'); + + if (!input.other_person_id) { + throw new Error('other_person_id is required'); + } + + const id1 = + personId < input.other_person_id ? personId : input.other_person_id; + const id2 = + personId < input.other_person_id ? input.other_person_id : personId; + + const { rows } = await pool.query( + `INSERT INTO person_connections + (person_id_1, person_id_2, connection_type, notes, start_year, end_year) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + id1, + id2, + input.connection_type, + input.notes || null, + input.start_year || null, + input.end_year || null, + ], + ); + + await logAudit(user.id, 'add_person_connection', { + personId, + otherId: input.other_person_id, + connectionType: input.connection_type, + connectionId: rows[0].id, + }); + + return { ...rows[0], _viewerId: personId }; + }, + + updatePersonConnection: async ( + _: unknown, + { + id, + input, + }: { + id: string; + input: PersonConnectionInput; + }, + context: Context, + ) => { + const user = requireAuth(context, 'editor'); + + const { rows: existing } = await pool.query( + 'SELECT * FROM person_connections WHERE id = $1', + [id], + ); + if (!existing[0]) return null; + + const { rows } = await pool.query( + `UPDATE person_connections + SET connection_type = $2, notes = $3, start_year = $4, end_year = $5 + WHERE id = $1 + RETURNING *`, + [ + id, + input.connection_type, + input.notes || null, + input.start_year || null, + input.end_year || null, + ], + ); + + await logAudit(user.id, 'update_person_connection', { + connectionId: id, + connectionType: input.connection_type, + }); + + return rows[0] || null; + }, + + removePersonConnection: async ( + _: unknown, + { id }: { id: string }, + context: Context, + ) => { + const user = requireAuth(context, 'editor'); + + const { rows: existing } = await pool.query( + 'SELECT * FROM person_connections WHERE id = $1', + [id], + ); + + await pool.query('DELETE FROM person_connections WHERE id = $1', [id]); + + await logAudit(user.id, 'remove_person_connection', { + connectionId: id, + person_id_1: existing[0]?.person_id_1, + person_id_2: existing[0]?.person_id_2, + connectionType: existing[0]?.connection_type, + }); + + return true; + }, + }, +}; diff --git a/lib/graphql/schema/mutations.ts b/lib/graphql/schema/mutations.ts index 2e57ffdd..e9156451 100644 --- a/lib/graphql/schema/mutations.ts +++ b/lib/graphql/schema/mutations.ts @@ -123,6 +123,11 @@ export const mutationTypes = ` updateComment(id: ID!, content: String!): Comment deleteComment(id: ID!): Boolean! + # Person connection mutations (non-family relationships) + addPersonConnection(personId: ID!, input: PersonConnectionInput!): PersonConnection! + updatePersonConnection(id: ID!, input: PersonConnectionInput!): PersonConnection + removePersonConnection(id: ID!): Boolean! + # Alternate name mutations (Issue #458) addAlternateName(personId: ID!, input: AlternateNameInput!): AlternateName! updateAlternateName(id: ID!, input: AlternateNameInput!): AlternateName diff --git a/lib/graphql/schema/types/person.ts b/lib/graphql/schema/types/person.ts index 31c33e65..b9d81834 100644 --- a/lib/graphql/schema/types/person.ts +++ b/lib/graphql/schema/types/person.ts @@ -90,6 +90,9 @@ export const personTypes = ` # Notable relatives connected through ancestry notableRelatives: [NotableRelative!]! + # Non-family connections (friends, colleagues, neighbors, etc.) + connections: [PersonConnection!]! + # Descendant count for tree navigation (Issue #342) descendant_count: Int @@ -255,6 +258,31 @@ export const personTypes = ` notes: String } + # =========================================== + # PERSON CONNECTIONS + # =========================================== + + type PersonConnection { + id: ID! + person_id_1: String! + person_id_2: String! + connection_type: String! + notes: String + start_year: Int + end_year: Int + created_at: String + # The other person in the connection (relative to the viewer) + person: Person! + } + + input PersonConnectionInput { + other_person_id: ID + connection_type: String! + notes: String + start_year: Int + end_year: Int + } + input AlternateNameInput { name_type: String name_given: String diff --git a/lib/migrations/index.ts b/lib/migrations/index.ts index 9d341f7b..cdbfceb0 100644 --- a/lib/migrations/index.ts +++ b/lib/migrations/index.ts @@ -37,6 +37,7 @@ import { migrations as v35Migrations } from './v35-media-tags'; import { migrations as v36Migrations } from './v36-unified-life-events'; import { migrations as v38Migrations } from './v38-remove-notes-column'; import { migrations as v39Migrations } from './v39-family-research-notes'; +import { migrations as v40Migrations } from './v40-person-connections'; // Re-export types export type { Migration, MigrationResult, MigrationStatus } from './types'; @@ -60,6 +61,7 @@ export const migrations: Migration[] = [ ...v36Migrations, ...v38Migrations, ...v39Migrations, + ...v40Migrations, ].sort((a, b) => a.version - b.version); // Advisory lock ID for preventing concurrent migrations diff --git a/lib/migrations/v40-person-connections.ts b/lib/migrations/v40-person-connections.ts new file mode 100644 index 00000000..8abffef8 --- /dev/null +++ b/lib/migrations/v40-person-connections.ts @@ -0,0 +1,58 @@ +/** + * Migration v40: Person connections table + * + * Adds support for non-family relationships between people + * (friend, colleague, neighbor, employer/employee, godparent/godchild, other). + * + * @module lib/migrations/v40-person-connections + */ + +import type { Pool } from 'pg'; +import type { Migration } from './types'; + +export const migrations: Migration[] = [ + { + version: 40, + name: 'person_connections', + up: async (pool: Pool) => { + const results: string[] = []; + + await pool.query(` + CREATE TABLE IF NOT EXISTS person_connections ( + id SERIAL PRIMARY KEY, + person_id_1 VARCHAR(255) NOT NULL REFERENCES people(id) ON DELETE CASCADE, + person_id_2 VARCHAR(255) NOT NULL REFERENCES people(id) ON DELETE CASCADE, + connection_type VARCHAR(50) NOT NULL + CHECK (connection_type IN ( + 'friend', 'colleague', 'neighbor', + 'employer', 'employee', + 'godparent', 'godchild', 'other' + )), + notes TEXT, + start_year INT, + end_year INT, + created_at TIMESTAMP DEFAULT NOW(), + CONSTRAINT person_connections_no_self + CHECK (person_id_1 <> person_id_2), + CONSTRAINT person_connections_canonical_order + CHECK (person_id_1 < person_id_2), + CONSTRAINT person_connections_unique + UNIQUE (person_id_1, person_id_2, connection_type) + ) + `); + results.push('Created person_connections table'); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_person_connections_p1 + ON person_connections (person_id_1) + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_person_connections_p2 + ON person_connections (person_id_2) + `); + results.push('Created indexes on person_connections'); + + return results; + }, + }, +]; diff --git a/lib/types.ts b/lib/types.ts index af0666de..001af2f5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -136,6 +136,21 @@ export interface Fact { fact_value: string | null; } +/** + * Non-family connection between two people (friend, colleague, neighbor, etc.) + */ +export interface PersonConnection { + id: number; + person_id_1: string; + person_id_2: string; + connection_type: string; + notes: string | null; + start_year: number | null; + end_year: number | null; + created_at: string | null; + _viewerId?: string; +} + /** * Alternate name record (maiden, married, nickname, etc.) */ From 400a673ec97847614c95b11f25d77fef39236159 Mon Sep 17 00:00:00 2001 From: Peter Milanese Date: Mon, 4 May 2026 14:53:47 -0400 Subject: [PATCH 2/3] fix: fetch connections in standalone query to avoid crashing GET_PERSON Moving connections out of GET_PERSON prevents the CI test DB (which doesn't run migrations) from breaking the whole person page when the person_connections table doesn't yet exist. The card now owns its own GET_PERSON_CONNECTIONS query, mirroring how CommentsSection works. Co-Authored-By: Claude Sonnet 4.6 --- components/PersonPageClient.tsx | 11 ----------- components/person/PersonConnectionsCard.tsx | 21 ++++++++++++++------- lib/graphql/queries/index.ts | 1 + lib/graphql/queries/person.ts | 15 ++++++++++++--- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/components/PersonPageClient.tsx b/components/PersonPageClient.tsx index b68bc600..12e7435f 100644 --- a/components/PersonPageClient.tsx +++ b/components/PersonPageClient.tsx @@ -35,7 +35,6 @@ import type { LifeEvent, Media, Person, - PersonConnection, Source, } from '@/lib/types'; @@ -55,15 +54,6 @@ interface PersonData { facts: Fact[]; sources: Source[]; alternateNames: AlternateName[]; - connections: (PersonConnection & { - person: { - id: string; - name_full: string; - birth_year?: number | null; - death_year?: number | null; - sex?: string | null; - }; - })[]; media: Media[]; }) | null; @@ -256,7 +246,6 @@ export default function PersonPageClient({ personId }: Props) { {/* Non-family connections */} diff --git a/components/person/PersonConnectionsCard.tsx b/components/person/PersonConnectionsCard.tsx index d6a6c40c..f3ed27a9 100644 --- a/components/person/PersonConnectionsCard.tsx +++ b/components/person/PersonConnectionsCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMutation } from '@apollo/client/react'; +import { useMutation, useQuery } from '@apollo/client/react'; import { Pencil, Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -26,7 +26,7 @@ import { } from '@/components/ui'; import { ADD_PERSON_CONNECTION, - GET_PERSON, + GET_PERSON_CONNECTIONS, REMOVE_PERSON_CONNECTION, UPDATE_PERSON_CONNECTION, } from '@/lib/graphql/queries'; @@ -54,7 +54,6 @@ type ConnectionWithPerson = PersonConnection & { person: ConnectedPerson }; interface PersonConnectionsCardProps { personId: string; - connections: ConnectionWithPerson[]; canEdit: boolean; } @@ -68,14 +67,21 @@ const emptyForm = { export default function PersonConnectionsCard({ personId, - connections, canEdit, }: PersonConnectionsCardProps) { const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [formData, setFormData] = useState(emptyForm); - const refetchQueries = [{ query: GET_PERSON, variables: { id: personId } }]; + const { data, loading } = useQuery<{ + person: { connections: ConnectionWithPerson[] } | null; + }>(GET_PERSON_CONNECTIONS, { variables: { id: personId } }); + + const connections = data?.person?.connections ?? []; + + const refetchQueries = [ + { query: GET_PERSON_CONNECTIONS, variables: { id: personId } }, + ]; const [addConnection, { loading: adding }] = useMutation( ADD_PERSON_CONNECTION, @@ -131,8 +137,8 @@ export default function PersonConnectionsCard({ other_person_id: formData.other_person_id || undefined, connection_type: formData.connection_type, notes: formData.notes || null, - start_year: formData.start_year ? parseInt(formData.start_year) : null, - end_year: formData.end_year ? parseInt(formData.end_year) : null, + start_year: formData.start_year ? parseInt(formData.start_year, 10) : null, + end_year: formData.end_year ? parseInt(formData.end_year, 10) : null, }; if (editingId) { await updateConnection({ variables: { id: editingId, input } }); @@ -151,6 +157,7 @@ export default function PersonConnectionsCard({ } }; + if (loading) return null; if (connections.length === 0 && !canEdit) return null; return ( diff --git a/lib/graphql/queries/index.ts b/lib/graphql/queries/index.ts index b5c161f1..c255981d 100644 --- a/lib/graphql/queries/index.ts +++ b/lib/graphql/queries/index.ts @@ -109,6 +109,7 @@ export { GET_PEOPLE, GET_PEOPLE_LIST, GET_PERSON, + GET_PERSON_CONNECTIONS, GET_PERSON_SOURCES, GET_RECENT_PEOPLE, GET_SEARCH_SUGGESTIONS, diff --git a/lib/graphql/queries/person.ts b/lib/graphql/queries/person.ts index 32f1364f..d490384b 100644 --- a/lib/graphql/queries/person.ts +++ b/lib/graphql/queries/person.ts @@ -104,6 +104,18 @@ export const GET_PERSON = gql` is_primary notes } + media { + ...MediaFields + } + } + } +`; + +// Get connections for a person (separate query so a missing table doesn't break the person page) +export const GET_PERSON_CONNECTIONS = gql` + query GetPersonConnections($id: ID!) { + person(id: $id) { + id connections { id person_id_1 @@ -120,9 +132,6 @@ export const GET_PERSON = gql` sex } } - media { - ...MediaFields - } } } `; From c69bde92e9dd0caa58b30b7c157ef68a7c170a3b Mon Sep 17 00:00:00 2001 From: Peter Milanese Date: Mon, 4 May 2026 15:00:51 -0400 Subject: [PATCH 3/3] fix: demote PersonHeroCard name from h1 to h2 PageHeader already renders an h1 for the page title; having a second h1 in the hero card is invalid HTML and breaks the Playwright strict-mode locator that expects a single h1. Co-Authored-By: Claude Sonnet 4.6 --- components/person/PersonHeroCard.tsx | 110 ++++++++++++++++++++------- 1 file changed, 83 insertions(+), 27 deletions(-) diff --git a/components/person/PersonHeroCard.tsx b/components/person/PersonHeroCard.tsx index ab5d4dd3..2028edbe 100644 --- a/components/person/PersonHeroCard.tsx +++ b/components/person/PersonHeroCard.tsx @@ -8,6 +8,7 @@ import { AvatarFallback, AvatarImage, Badge, + Button, Card, CardContent, Separator, @@ -15,7 +16,6 @@ import { TooltipContent, TooltipProvider, TooltipTrigger, - Button, } from '@/components/ui'; import type { AlternateName, Fact, LifeEvent, Person } from '@/lib/types'; @@ -37,12 +37,18 @@ interface PersonHeroCardProps { } /** Extract a vital event from the life events array */ -function getVitalEvent(events: LifeEvent[], type: string): LifeEvent | undefined { +function getVitalEvent( + events: LifeEvent[], + type: string, +): LifeEvent | undefined { return events.find((e) => e.event_type === type); } /** Get biography text from facts or description */ -function getBiography(facts: Fact[], description?: string | null): string | null { +function getBiography( + facts: Fact[], + description?: string | null, +): string | null { const bioFact = facts.find((f) => f.fact_type === 'Biography'); if (bioFact?.fact_value) return bioFact.fact_value; if (description) return description; @@ -114,11 +120,15 @@ export default function PersonHeroCard({ {/* Top row: Photo + Name + Actions */}
{/* Photo */} - + {photoUrl ? ( ) : null} - + {getInitials(person.name_full)} @@ -127,19 +137,28 @@ export default function PersonHeroCard({
-

+

{person.name_full} -

+
- {subtitle} + + {subtitle} + {isFemale ? t('female') : t('male')} {person.living && ( - + {t('living')} )} @@ -161,12 +180,16 @@ export default function PersonHeroCard({ variant="ghost" size="icon-sm" onClick={onToggleThisIsMe} - className={isMe ? 'text-green-600' : 'text-muted-foreground'} + className={ + isMe ? 'text-green-600' : 'text-muted-foreground' + } > - {isMe ? t('unlinkMe') : t('markAsMe')} + + {isMe ? t('unlinkMe') : t('markAsMe')} + )} {canEdit && ( @@ -177,16 +200,30 @@ export default function PersonHeroCard({ variant="ghost" size="icon-sm" onClick={onToggleNotable} - className={person.is_notable ? 'text-amber-500' : 'text-muted-foreground'} + className={ + person.is_notable + ? 'text-amber-500' + : 'text-muted-foreground' + } > - + - {person.is_notable ? t('removeNotableStatus') : t('markAsNotableTitle')} + + {person.is_notable + ? t('removeNotableStatus') + : t('markAsNotableTitle')} + - @@ -238,7 +275,11 @@ export default function PersonHeroCard({ @@ -246,7 +287,11 @@ export default function PersonHeroCard({ @@ -264,7 +309,10 @@ export default function PersonHeroCard({ @@ -287,7 +335,9 @@ export default function PersonHeroCard({ const type = n.name_type ? ` (${n.name_type})` : ''; return ( - {name}{type}{i < alternateNames.length - 1 ? ', ' : ''} + {name} + {type} + {i < alternateNames.length - 1 ? ', ' : ''} ); })} @@ -341,7 +391,9 @@ function VitalStat({
{icon} - {label} + + {label} +

Unknown

@@ -349,16 +401,20 @@ function VitalStat({ } return ( -
+
{icon} - {label} + + {label} +
- {date && ( -

{date}

- )} + {date &&

{date}

} {place && ( -

{place}

+

+ {place} +

)}
);