From eaecac8fa52f7f364b9bb685bd42a79e8fc42378 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Tue, 20 Oct 2026 14:25:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(personnes):=20saisir=20le=20sexe=20et=20no?= =?UTF-8?q?mmer=20les=20liens=20de=20parent=C3=A9=20en=20cons=C3=A9quence?= =?UTF-8?q?=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Champ Sexe (femme, homme, non précisé) dans la fiche en modification, à l'ajout d'une personne et dans une proposition de la famille ; relu « Sexe » dans les propositions à relire. - Valeurs ramenées à male / female / null à l'écriture et à la lecture (anciennes valeurs M/F relues sans migration), valeur inconnue refusée (400). Export GEDCOM inchangé dans sa forme. - Lien au survol genré quand le sexe est connu (Père, Tante, Cousine germaine, Belle-mère…), forme double sinon. Nommage déplacé dans utils/kinship : une fonction par langue reçoit le chemin complet (pas, personnes, sexe, année de naissance), pour brancher d'autres langues. - Anglais : beau-parent distingué de parent par alliance selon l'ordre du chemin (Stepmother / Mother-in-law). - Propositions à relire : le nom de la personne modifiée vient de l'avant lu en base quand la modification ne porte que le champ changé. Co-Authored-By: Claude Opus 5.5 --- backend/src/lib/person-sex.ts | 34 ++++++ backend/src/routes/contributions.ts | 6 +- backend/src/routes/gedcom.ts | 14 +-- backend/src/routes/trees.ts | 5 +- backend/src/utils/graph-payload.ts | 3 +- backend/test/api-routes.test.ts | 26 ++++- backend/test/e2e-journeys.test.ts | 53 ++++++++++ backend/test/person-sex.test.ts | 32 ++++++ frontend/src/App.jsx | 8 ++ frontend/src/components/AddPersonPanel.jsx | 26 ++++- frontend/src/components/ContributionPanel.jsx | 21 ++-- frontend/src/components/Galaxy.jsx | 2 +- frontend/src/components/PersonCard.css | 3 + frontend/src/components/PersonCard.jsx | 23 +++- frontend/src/components/galaxy/pathfinding.js | 97 +++++------------ frontend/src/i18n/en/arbre.js | 25 +---- frontend/src/i18n/en/fiche.js | 11 ++ frontend/src/utils/graphAdapter.js | 3 +- frontend/src/utils/kinship/en.js | 46 ++++++++ frontend/src/utils/kinship/facts.js | 23 ++++ frontend/src/utils/kinship/fr.js | 43 ++++++++ frontend/src/utils/kinship/index.js | 24 +++++ frontend/src/utils/kinship/kinship.test.js | 100 ++++++++++++++++++ frontend/src/utils/personSex.js | 10 ++ 24 files changed, 521 insertions(+), 117 deletions(-) create mode 100644 backend/src/lib/person-sex.ts create mode 100644 backend/test/person-sex.test.ts create mode 100644 frontend/src/utils/kinship/en.js create mode 100644 frontend/src/utils/kinship/facts.js create mode 100644 frontend/src/utils/kinship/fr.js create mode 100644 frontend/src/utils/kinship/index.js create mode 100644 frontend/src/utils/kinship/kinship.test.js create mode 100644 frontend/src/utils/personSex.js diff --git a/backend/src/lib/person-sex.ts b/backend/src/lib/person-sex.ts new file mode 100644 index 0000000..632ea9e --- /dev/null +++ b/backend/src/lib/person-sex.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' + +// Sexe d'une personne : 'male', 'female', ou null (non précisé). L'import GEDCOM écrit 'male'/'female', +// d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est ramené à ces deux valeurs, à l'écriture +// comme à la lecture, sans migration des lignes existantes. +export type PersonSex = 'male' | 'female' + +const MALE = new Set(['male', 'm', 'homme', 'h']) +const FEMALE = new Set(['female', 'f', 'femme']) +const UNSPECIFIED = new Set(['', 'u', 'unknown', 'unspecified']) + +// undefined : valeur que l'on ne sait pas lire (refusée à l'écriture) +export function parseSex(value: string | null): PersonSex | null | undefined { + if (value === null) return null + const key = value.trim().toLowerCase() + if (MALE.has(key)) return 'male' + if (FEMALE.has(key)) return 'female' + if (UNSPECIFIED.has(key)) return null + return undefined +} + +// Lecture d'une valeur déjà en base : ce qu'on ne sait pas lire vaut « non précisé » +export function sexOrNull(value: string | null | undefined): PersonSex | null { + return parseSex(value ?? null) ?? null +} + +// Champ « sex » des schémas d'écriture : facultatif, null pour « non précisé », valeur ramenée à male/female +export const sexSchema = z + .string() + .max(20) + .nullable() + .refine((value) => parseSex(value) !== undefined, { message: 'invalid_sex' }) + .transform((value) => parseSex(value) ?? null) + .optional() diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index b79eab4..4dcd8bb 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -14,6 +14,7 @@ import { findParentChildValidationError, findUnionValidationError } from '../uti import { nextSiblingOrder } from '../lib/sibling-order.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' import { hasAnyName } from '../lib/person-names.js' +import { sexOrNull, sexSchema } from '../lib/person-sex.js' const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ @@ -81,7 +82,7 @@ const personCreateSchema = z.object({ profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), }).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) @@ -99,7 +100,7 @@ const personUpdateSchema = z profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), }) .refine((value) => Object.keys(value).length > 0, { @@ -869,6 +870,7 @@ async function snapshotEntity( ...current, birthDate: current.birthDate?.toISOString().slice(0, 10) ?? null, deathDate: current.deathDate?.toISOString().slice(0, 10) ?? null, + sex: sexOrNull(current.sex), } } diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 3419d8f..21111ee 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -8,6 +8,7 @@ import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } import type { GedcomRecords } from '../lib/gedcom-photos.js' import type { MembershipRole } from '../types/auth.js' import { nextSiblingOrder } from '../lib/sibling-order.js' +import { sexOrNull } from '../lib/person-sex.js' const treeIdParamSchema = z.object({ id: z.string().min(1), @@ -455,8 +456,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`) if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`) - if (person.sex === 'male' || person.sex === 'M') lines.push('1 SEX M') - else if (person.sex === 'female' || person.sex === 'F') lines.push('1 SEX F') + const sex = sexOrNull(person.sex) + if (sex === 'male') lines.push('1 SEX M') + else if (sex === 'female') lines.push('1 SEX F') const personEvents = eventsByPerson.get(person.id) ?? [] const birthDetails = personEvents.find((event) => event.type === 'birth') @@ -518,9 +520,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const p1 = union.partner1PersonId ? personById.get(union.partner1PersonId) : null const p2 = union.partner2PersonId ? personById.get(union.partner2PersonId) : null - const p1IsFemale = p1?.sex === 'female' || p1?.sex === 'F' - const p1IsMale = p1?.sex === 'male' || p1?.sex === 'M' - const p2IsMale = p2?.sex === 'male' || p2?.sex === 'M' + const p1IsFemale = sexOrNull(p1?.sex) === 'female' + const p1IsMale = sexOrNull(p1?.sex) === 'male' + const p2IsMale = sexOrNull(p2?.sex) === 'male' // Swap to HUSB=male if we know p1 is female and p2 is male (or unknown) const shouldSwap = p1IsFemale && !p2IsMale === false ? false : p1IsFemale && (p2IsMale || !p1IsMale) @@ -675,7 +677,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { : { firstName: '', lastName: '' } const sexNode = findChild(indi, 'SEX') - const sex = sexNode?.value === 'M' ? 'male' : sexNode?.value === 'F' ? 'female' : null + const sex = sexOrNull(sexNode?.value) const birtNode = findChild(indi, 'BIRT') const birthDateIso = gedcomDateToIso(findChild(birtNode, 'DATE')?.value ?? '') diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 3ce83c1..2035c78 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -17,6 +17,7 @@ import type { MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' import { hasAnyName } from '../lib/person-names.js' +import { sexSchema } from '../lib/person-sex.js' import { ContributionRouteError, resolveSubmissionContext } from './contributions.js' const MAX_TREES_PER_ACCOUNT = 10 @@ -97,7 +98,7 @@ const createPersonSchema = z.object({ profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), }).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) @@ -115,7 +116,7 @@ const patchPersonSchema = z profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), }) .refine((value) => Object.keys(value).length > 0, { diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index a789959..745919c 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -3,6 +3,7 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@pri import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js' import { isImmichAvatarPath } from '../lib/media-files.js' +import { sexOrNull } from '../lib/person-sex.js' export type GraphPayload = { persons: Array<{ @@ -98,7 +99,7 @@ export function buildGraphPayload( region: person.region, nationality: person.nationality, avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null, - sex: person.sex, + sex: sexOrNull(person.sex), notes: person.notes, avatarFromImmich: isImmichAvatarPath(person.avatarPath), avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index cb0a1d9..3ec27e2 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -491,7 +491,7 @@ describe('Graph routes', () => { prismaMock.person.findMany.mockResolvedValue([ { ...base, id: 'p-1', firstName: 'Louis', lastName: 'Ragot', sex: 'male', birthDate: new Date('1879-01-01T00:00:00Z'), birthDatePrecision: 'about', birthPlace: 'La Roche-sur-Yon', deathDate: new Date('1950-05-02T00:00:00Z'), deathPlace: 'Nantes', nickname: 'Loulou', profession: 'Instituteur' }, { ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null, isDeceased: true }, - { ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'male', birthDate: null, birthDatePrecision: null, birthPlace: null }, + { ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'M', birthDate: null, birthDatePrecision: null, birthPlace: null }, ]) prismaMock.union.findMany.mockResolvedValue([ { id: 'u-1', partner1PersonId: 'p-1', partner2PersonId: 'p-2', startDate: new Date('1905-01-01T00:00:00Z'), startDatePrecision: 'estimated', startPlace: 'Limoges', endDate: null }, @@ -518,6 +518,8 @@ describe('Graph routes', () => { expect(text).toContain('2 PEDI adopted') // Décédée sans date (issue #77) : la forme courte de la norme expect(text).toContain('1 NAME Marie /Bodin/\n1 SEX F\n1 DEAT Y\n') + // Ancienne écriture « M » en base : exportée comme un homme (issue #114) + expect(text).toContain('1 NAME Jean /Ragot/\n1 SEX M\n') const roots = parseGedcom(text) const records = indexGedcomRecords(roots) @@ -1147,6 +1149,28 @@ describe('Surnom (nickname) en écriture directe', () => { }) }) +describe('Sexe d’une personne en écriture directe (issue #114)', () => { + it('PATCH ramène le sexe à male / female, null = non précisé, refuse une valeur inconnue', async () => { + const app = createApp() + const token = await createUserToken(app) + const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload }) + + expect((await patch({ sex: 'F' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' }) + + expect((await patch({ sex: 'male' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' }) + + expect((await patch({ sex: null })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: null }) + + const calls = prismaMock.person.updateMany.mock.calls.length + expect((await patch({ sex: 'robot' })).statusCode).toBe(400) + expect(prismaMock.person.updateMany.mock.calls.length).toBe(calls) + await app.close() + }) +}) + describe('Lieu de décès et filiation adoptive (écriture)', () => { it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => { const app = createApp() diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index 1a08cdf..cbf4a3c 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -278,6 +278,7 @@ describe('Journey 1 — Owner creates account, tree, and adds family', () => { }, }) expect(addSelfRes.statusCode).toBe(201) + expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' }) // Step 4: Add parent prismaMock.person.create.mockResolvedValue({ id: 'person-2', firstName: 'Jean', lastName: 'Martin' }) @@ -806,3 +807,55 @@ describe('Surnom (nickname) proposé par un contributeur', () => { await app.close() }) }) + +describe('Sexe proposé par un contributeur (issue #114)', () => { + it('appliqué par le propriétaire avec la valeur normalisée', async () => { + const app = createApp() + await app.ready() + + const adminToken = (app as any).jwt.sign( + { kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' }, + { expiresIn: '1h' }, + ) + prismaMock.contributionSession.findFirst.mockResolvedValue({ + id: 'session-s', + treeId: 'tree-1', + status: 'pending', + title: 'Sexe de Camille', + changes: [ + { + id: 'change-s1', + entityType: 'person', + action: 'update', + entityId: 'person-1', + beforeJson: { sex: null }, + afterJson: { sex: 'F' }, + conflictState: 'none', + }, + { + id: 'change-s2', + entityType: 'person', + action: 'create', + entityId: null, + beforeJson: null, + afterJson: { firstName: 'Léon', lastName: 'Martin', sex: 'male' }, + conflictState: 'none', + }, + ], + }) + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Camille', lastName: 'Martin', sex: null, deletedAt: null }) + + const reviewRes = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions/session-s/review', + headers: { authorization: `Bearer ${adminToken}` }, + payload: { decision: 'approved' }, + }) + expect(reviewRes.statusCode).toBe(200) + expect(reviewRes.json().approvedCount).toBe(2) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' }) + expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' }) + + await app.close() + }) +}) diff --git a/backend/test/person-sex.test.ts b/backend/test/person-sex.test.ts new file mode 100644 index 0000000..1efcaa0 --- /dev/null +++ b/backend/test/person-sex.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { parseSex, sexOrNull, sexSchema } from '../src/lib/person-sex' + +// Sexe d'une personne (issue #114) : male, female ou null, anciennes valeurs M/F relues +describe('sexe d’une personne', () => { + it('ramène les écritures connues à male / female / null', () => { + expect(parseSex('male')).toBe('male') + expect(parseSex('M')).toBe('male') + expect(parseSex(' homme ')).toBe('male') + expect(parseSex('female')).toBe('female') + expect(parseSex('F')).toBe('female') + expect(parseSex('Femme')).toBe('female') + expect(parseSex(null)).toBeNull() + expect(parseSex('')).toBeNull() + expect(parseSex('U')).toBeNull() + expect(parseSex('robot')).toBeUndefined() + }) + + it('une valeur illisible déjà en base se lit « non précisé »', () => { + expect(sexOrNull('M')).toBe('male') + expect(sexOrNull('robot')).toBeNull() + expect(sexOrNull(undefined)).toBeNull() + }) + + it('le schéma d’écriture normalise, garde l’absence et refuse l’inconnu', () => { + expect(sexSchema.parse('F')).toBe('female') + expect(sexSchema.parse(null)).toBeNull() + expect(sexSchema.parse(undefined)).toBeUndefined() + expect(sexSchema.safeParse('robot').success).toBe(false) + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ba58e00..0122849 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -247,6 +247,7 @@ function buildContributionChangesFromDraft(draft = {}) { lastName: String(person.lastName).trim(), birthName: person.birthName ? String(person.birthName).trim() : null, nickname: person.nickname ? String(person.nickname).trim() : null, + sex: person.sex || null, birthDate: person.birthDate || null, deathDate: person.deathDate || null, isDeceased: !person.deathDate && Boolean(person.isDeceased), @@ -302,6 +303,7 @@ function buildContributionChangesFromDraft(draft = {}) { if (Object.prototype.hasOwnProperty.call(source, 'lastName')) after.lastName = source.lastName || '' if (Object.prototype.hasOwnProperty.call(source, 'birthName')) after.birthName = (source.birthName || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'nickname')) after.nickname = (source.nickname || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'sex')) after.sex = source.sex || null if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear) if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear) if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null @@ -1062,6 +1064,7 @@ function App() { lastName: formData.lastName || '', birthName: formData.birthName || null, nickname: formData.nickname || null, + sex: formData.sex || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, birthYear, @@ -1142,6 +1145,7 @@ function App() { lastName: formData.lastName, birthName: formData.birthName || null, nickname: formData.nickname || null, + sex: formData.sex || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, isDeceased: Boolean(formData.isDeceased), @@ -1169,6 +1173,7 @@ function App() { lastName: formData.lastName, birthName: formData.birthName, nickname: formData.nickname, + sex: formData.sex || null, birthDate: formData.birthDate, deathDate: formData.deathDate, isDeceased: Boolean(formData.isDeceased), @@ -1300,6 +1305,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(rawChanges, 'lastName')) payload.lastName = rawChanges.lastName || '' if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthName')) payload.birthName = (rawChanges.birthName || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'nickname')) payload.nickname = (rawChanges.nickname || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'sex')) payload.sex = rawChanges.sex || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null @@ -1576,6 +1582,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) localPerson.lastName = changes.lastName || '' if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) localPerson.birthName = changes.birthName || null if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) localPerson.nickname = changes.nickname || null + if (Object.prototype.hasOwnProperty.call(changes, 'sex')) localPerson.sex = changes.sex || null if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) { const iso = yearToIsoDate(changes.birthYear) localPerson.birthDate = iso @@ -1614,6 +1621,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) payload.lastName = changes.lastName || '' if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) payload.birthName = (changes.birthName || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) payload.nickname = (changes.nickname || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'sex')) payload.sex = changes.sex || null if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear) if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear) if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index 36e5293..ada74cf 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -5,6 +5,7 @@ import './AddPersonPanel.css' import PzBusy from './PzBusy.jsx' import { t } from '../i18n/index.js' import { firstNameLabel } from '../utils/personName.js' +import { SEX_CHOICES } from '../utils/personSex.js' // Titre traduit d'un bloc : le passage entre et garde son italique function withEmphasis(text) { @@ -38,6 +39,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const [lastName, setLastName] = useState('') const [birthName, setBirthName] = useState('') const [nickname, setNickname] = useState('') + const [sex, setSex] = useState(null) const [birthDate, setBirthDate] = useState('') const [deathDate, setDeathDate] = useState('') // Décédé(e) sans date connue (issue #77) @@ -101,6 +103,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on setLastName('') setBirthName('') setNickname('') + setSex(null) setBirthDate('') setDeathDate('') setIsDeceased(false) @@ -156,6 +159,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on lastName: lastName.trim(), birthName: birthName.trim() || null, nickname: nickname.trim() || null, + sex, birthDate: birthDate || null, deathDate: deathDate || null, isDeceased: !deathDate && isDeceased, @@ -271,6 +275,24 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on /> +
+ {t('Sexe')} {t('facultatif')} +
+ {SEX_CHOICES.map(({ value, label }) => ( + + ))} +
+
+
@@ -282,7 +304,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on {!deathDate && ( )}
@@ -347,7 +369,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on {(selectedRelationType === 'child' || selectedRelationType === 'parent') && ( )} diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index 89eb67d..2610c0d 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -7,6 +7,7 @@ import './SettingsPanels.css' import PzBusy from './PzBusy.jsx' import { HELP_URL } from '../utils/siteLinks.js' import { t, dateLocale } from '../i18n/index.js' +import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js' // ─── helpers ──────────────────────────────────────────────────────────────── @@ -74,6 +75,7 @@ const PERSON_FIELD_LABELS = { lastName: t('Nom'), birthName: t('Nom de naissance'), nickname: t('Surnom'), + sex: t('Sexe'), birthDate: t('Date de naissance'), deathDate: t('Date de décès'), deathPlace: t('Lieu de décès'), @@ -84,7 +86,9 @@ const PERSON_FIELD_LABELS = { notes: t('Notes'), } -function displayVal(v) { +function displayVal(v, key) { + // Sexe : « Non précisé » plutôt que « vide » + if (key === 'sex') return SEX_CHOICES.find((choice) => choice.value === normalizeSex(v))?.label ?? null if (v === null || v === undefined || v === '') return null return String(v) } @@ -134,10 +138,11 @@ function getPersonDisplayName(change, sessionChanges = []) { const data = change.afterJson || {} return data.title || t('Moment') } - const data = (change.afterJson && typeof change.afterJson === 'object') - ? change.afterJson - : (change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null) - if (!data) return null + // Une modification ne porte que les champs changés : le nom vient alors de l'« avant » (lu en base) + const after = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null + const before = change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null + if (!after && !before) return null + const data = { ...before, ...after } const name = `${data.firstName || ''} ${data.lastName || ''}`.trim() return name || null } @@ -614,12 +619,12 @@ const ContributionPanel = ({ {label} {oldVal !== undefined ? ( - {displayVal(oldVal) ?? t('vide')} + {displayVal(oldVal, key) ?? t('vide')} → - {displayVal(newVal) ?? t('vide')} + {displayVal(newVal, key) ?? t('vide')} ) : ( - {displayVal(newVal) ?? t('vide')} + {displayVal(newVal, key) ?? t('vide')} )}
))} diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 0cbf709..20e168c 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -798,7 +798,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig dirty = true lastPathInputRef.current = { selected: selectedElkId, hovered: currentHovered } if (selectedElkId && currentHovered && currentHovered !== selectedElkId && currentHovered.startsWith('p-')) { - const result = findPathBFS(selectedElkId, currentHovered, layoutData) + const result = findPathBFS(selectedElkId, currentHovered, layoutData, graphPersons) pathNodesRef.current = result ? result.nodePath : [] pathEdgesRef.current = result ? result.edgePath : [] pathLabelRef.current = result ? result.label : '' diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index 8930ab2..14a4459 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -292,6 +292,9 @@ .pc-parentage { display: grid; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--pz-line-soft); } .pc-seg { width: 100%; } .pc-seg .pz-tab { flex: 1; } +/* Sexe (issue #114) : étiquette au-dessus, trois choix sur toute la largeur, chacun sur une ligne */ +.pc-row--seg { flex-direction: column; align-items: stretch; gap: 6px; } +.pc-row--seg .pz-tab { white-space: nowrap; padding: 0 8px; } /* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */ .pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index d50b17a..1661698 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -19,6 +19,7 @@ import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/pers import { isYearOnly } from '../utils/dateInput.js' import DateField from './DateField.jsx' import { runOnce } from '../utils/saveOnLeave.js' +import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js' // Aucune modification en cours (référence stable) const NO_EDITS = Object.freeze({}) @@ -319,6 +320,7 @@ const PersonCard = ({ const birthFilled = isFilled(getEditValue('birthYear', person.birthDate || person.birthYear)) const deathFilled = isFilled(getEditValue('deathYear', person.deathDate || person.deathYear)) const deceasedNow = editMode ? !getEditValue('isAlive', person.isAlive) : !person.isAlive + const editSex = normalizeSex(getEditValue('sex', person.sex)) const notesShown = editMode && (isFilled(getEditValue('note', person.note)) || openedHas('note')) // En lecture : toujours visible si demandé (naissance, décès d'une personne décédée), sinon si rempli const infoRow = (field, label, filled, content, always = false) => { @@ -921,6 +923,25 @@ const PersonCard = ({ {infoRow('birthPlace', t('Lieu'), isFilled(getEditValue('birthPlace', person.birthPlace)), renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace }))} {infoRow('birthName', t('Nom de naissance'), isFilled(getEditValue('birthName', person.birthName)), renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') }))} {infoRow('nickname', t('Surnom'), isFilled(getEditValue('nickname', person.nickname)), renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') }))} + {editMode && ( +
+ {t('Sexe')} +
+ {SEX_CHOICES.map(({ value, label }) => ( + + ))} +
+
+ )} {editMode && (
{t('Statut')} @@ -930,7 +951,7 @@ const PersonCard = ({ onClick={() => handleFieldChange('isAlive', !getEditValue('isAlive', person.isAlive))} > - {getEditValue('isAlive', person.isAlive) ? t('En vie') : t('Décédé(e)')} + {getEditValue('isAlive', person.isAlive) ? t('En vie') : editSex === 'male' ? t('Décédé') : editSex === 'female' ? t('Décédée') : t('Décédé(e)')}
)} diff --git a/frontend/src/components/galaxy/pathfinding.js b/frontend/src/components/galaxy/pathfinding.js index f3be3fd..a64a7de 100644 --- a/frontend/src/components/galaxy/pathfinding.js +++ b/frontend/src/components/galaxy/pathfinding.js @@ -1,5 +1,5 @@ import { coupleBarMeta } from './elkLayout' -import { t } from '../../i18n/index.js' +import { nameKinship, normalizeSex } from '../../utils/kinship/index.js' // ============================================================ // BFS — chemin le plus court entre deux personnes @@ -8,7 +8,8 @@ import { t } from '../../i18n/index.js' // noeuds intermédiaires invisibles ; seuls les noeuds personne // comptent comme "étapes". // ============================================================ -export function findPathBFS(fromElkId, toElkId, layoutData) { +// persons : les personnes à jour (sexe modifié sans recalcul du placement) ; à défaut, celles du placement +export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) { if (!layoutData || fromElkId === toElkId) return null // Construire l'adjacence @@ -63,78 +64,38 @@ export function findPathBFS(fromElkId, toElkId, layoutData) { } nodePath.unshift(fromElkId) - const label = computeRelationshipLabel(edgePath) - return { nodePath, edgePath, label } + const personById = persons + ? new Map(persons.map((person) => [`p-${person.id}`, person])) + : new Map((layoutData.children || []).filter((node) => node._type === 'person').map((node) => [node.id, node._data])) + const kinship = kinshipPath(edgePath, nodePath, (id) => personById.get(id)) + return { nodePath, edgePath, kinship, label: nameKinship(kinship) } } // ============================================================ -// Calcul du lien de parenté à partir du chemin BFS -// On analyse la direction à chaque union traversée : -// filiation→couple = UP (enfant → parent) -// couple→filiation = DOWN (parent → enfant) -// filiation→filiation = SIBLING (frères/sœurs, +1 up +1 down) -// couple→couple = SPOUSE (conjoints) -// Puis (ups, downs) donne le degré de parenté. +// Chemin de parenté complet, à partir du chemin BFS +// On lit la direction à chaque union traversée : +// filiation→couple = 'up' (enfant → parent) +// couple→filiation = 'down' (parent → enfant) +// filiation→filiation = 'sibling' (frère ou sœur) +// couple→couple = 'spouse' (conjoint) +// Chaque pas garde la personne atteinte (sexe, année de naissance) : +// le nommage, langue par langue, est dans utils/kinship. // ============================================================ -export function computeRelationshipLabel(edgePath) { - if (edgePath.length < 2) return '' +const MOVES = { 'filiation>couple': 'up', 'couple>filiation': 'down', 'filiation>filiation': 'sibling', 'couple>couple': 'spouse' } - let ups = 0, downs = 0, hasSpouse = false +const personFacts = (data, id) => ({ id: data?.id ?? id, sex: normalizeSex(data?.sex), birthYear: data?.birthYear ?? null }) - for (let i = 0; i < edgePath.length; i += 2) { - if (i + 1 >= edgePath.length) break - const entering = edgePath[i].type - const leaving = edgePath[i + 1].type - if (entering === 'couple' && leaving === 'couple') hasSpouse = true - else if (entering === 'couple' && leaving === 'filiation') downs++ - else if (entering === 'filiation' && leaving === 'couple') ups++ - else if (entering === 'filiation' && leaving === 'filiation') { ups++; downs++ } +export function kinshipPath(edgePath, nodePath = [], personOf = () => null) { + if (edgePath.length < 2) return null + const steps = [] + for (let i = 0; i + 1 < edgePath.length; i += 2) { + const move = MOVES[`${edgePath[i].type}>${edgePath[i + 1].type}`] + const id = nodePath[i + 2] + steps.push({ move, person: personFacts(personOf(id), id) }) } + return { from: personFacts(personOf(nodePath[0]), nodePath[0]), steps } +} - // Conjoint direct - if (hasSpouse && ups === 0 && downs === 0) return t('Conjoint(e)') - - // In-laws courants - if (hasSpouse) { - if (ups === 1 && downs === 0) return t('Beau-parent') - if (ups === 0 && downs === 1) return t('Bel-enfant') - if (ups === 1 && downs === 1) return t('Beau-frère/sœur') - } - - // Ascendants directs - let label - if (downs === 0 && ups > 0) { - if (ups === 1) label = t('Parent') - else if (ups === 2) label = t('Grand-parent') - else if (ups === 3) label = t('Arrière-grand-parent') - else label = t('Aïeul(e) {n}e gén.', { n: ups }) - } - // Descendants directs - else if (ups === 0 && downs > 0) { - if (downs === 1) label = t('Enfant') - else if (downs === 2) label = t('Petit-enfant') - else if (downs === 3) label = t('Arrière-petit-enfant') - else label = t('Descendant(e) {n}e gén.', { n: downs }) - } - // Fratrie - else if (ups === 1 && downs === 1) { label = t('Frère/Sœur') } - // Oncle / Tante - else if (ups === 2 && downs === 1) { label = t('Oncle/Tante') } - else if (ups === 3 && downs === 1) { label = t('Grand-oncle/tante') } - // Neveu / Nièce - else if (ups === 1 && downs === 2) { label = t('Neveu/Nièce') } - else if (ups === 1 && downs === 3) { label = t('Petit-neveu/nièce') } - // Cousins - else if (ups >= 2 && downs >= 2) { - const degree = Math.min(ups, downs) - 1 - const removed = Math.abs(ups - downs) - if (degree === 1 && removed === 0) label = t('Cousin(e) germain(e)') - else if (degree === 1) label = t('Petit-cousin(e)') - else if (removed === 0) label = t('Cousin(e) {n}e degré', { n: degree }) - else label = t('Cousin(e) éloigné(e)') - } - else { label = t('{n} liens', { n: ups + downs }) } - - if (hasSpouse && label) label = t('{label} par alliance', { label }) - return label +export function computeRelationshipLabel(edgePath, nodePath, personOf) { + return nameKinship(kinshipPath(edgePath, nodePath, personOf)) } diff --git a/frontend/src/i18n/en/arbre.js b/frontend/src/i18n/en/arbre.js index 6ababa6..1afee5d 100644 --- a/frontend/src/i18n/en/arbre.js +++ b/frontend/src/i18n/en/arbre.js @@ -12,30 +12,7 @@ export default { 'Voir l’enfant': 'See child', 'Inconnu': 'Unknown', - // Lien de parenté affiché au survol (components/galaxy/pathfinding.js) - 'Conjoint(e)': 'Spouse', - 'Beau-parent': 'Parent-in-law', - 'Bel-enfant': 'Child-in-law', - 'Beau-frère/sœur': 'Brother/sister-in-law', - 'Parent': 'Parent', - 'Grand-parent': 'Grandparent', - 'Arrière-grand-parent': 'Great-grandparent', - 'Aïeul(e) {n}e gén.': 'Ancestor, {n} generations up', - 'Enfant': 'Child', - 'Petit-enfant': 'Grandchild', - 'Arrière-petit-enfant': 'Great-grandchild', - 'Descendant(e) {n}e gén.': 'Descendant, {n} generations down', - 'Frère/Sœur': 'Sibling', - 'Oncle/Tante': 'Uncle/Aunt', - 'Grand-oncle/tante': 'Great-uncle/aunt', - 'Neveu/Nièce': 'Nephew/Niece', - 'Petit-neveu/nièce': 'Grandnephew/niece', - 'Cousin(e) germain(e)': 'First cousin', - 'Petit-cousin(e)': 'First cousin once removed', - 'Cousin(e) {n}e degré': 'Cousin, degree {n}', - 'Cousin(e) éloigné(e)': 'Distant cousin', - '{n} liens': '{n} steps apart', - '{label} par alliance': '{label} by marriage', + // Le lien de parenté affiché au survol se nomme langue par langue dans utils/kinship/ // Glisser pour ajouter une personne (components/ProximityFeedback.jsx) 'Conjoint': 'Spouse', diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 75018d8..2d0eba1 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -51,6 +51,15 @@ export default { 'Statut': 'Status', 'En vie': 'Living', 'Décédé(e)': 'Deceased', + 'Décédé': 'Deceased', + 'Décédée': 'Deceased', + // Sexe d'une personne (utils/personSex.js, fiche et ajout) + 'Sexe': 'Sex', + 'Femme': 'Female', + 'Homme': 'Male', + 'Non précisé': 'Not specified', + 'Adopté': 'Adopted', + 'Adoptée': 'Adopted', 'Décès': 'Died', 'Date de décès': 'Date of death', 'Âge': 'Age', @@ -413,6 +422,8 @@ export default { 'Date inconnue': 'Date unknown', 'date inconnue': 'date unknown', 'Décédé(e), date inconnue': 'Deceased, date unknown', + 'Décédé, date inconnue': 'Deceased, date unknown', + 'Décédée, date inconnue': 'Deceased, date unknown', 'Prénom inconnu': 'Unknown first name', 'Je ne connais pas son prénom': "I don't know their first name", 'Il faut au moins un nom si le prénom est inconnu.': 'A last name is required when the first name is unknown.', diff --git a/frontend/src/utils/graphAdapter.js b/frontend/src/utils/graphAdapter.js index 9ef7b63..00ab1c3 100644 --- a/frontend/src/utils/graphAdapter.js +++ b/frontend/src/utils/graphAdapter.js @@ -4,6 +4,7 @@ */ import { t } from '../i18n/index.js' +import { normalizeSex } from './kinship/index.js' /** * Convertit une date ISO (YYYY-MM-DD) en année @@ -53,7 +54,7 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) { birthYear: dateToYear(p.birthDate), deathYear: dateToYear(p.deathDate), isAlive: isAlive(p.deathDate), - sex: p.sex || null, + sex: normalizeSex(p.sex), note: p.notes || null, birthDate: p.birthDate, deathDate: p.deathDate, diff --git a/frontend/src/utils/kinship/en.js b/frontend/src/utils/kinship/en.js new file mode 100644 index 0000000..e684313 --- /dev/null +++ b/frontend/src/utils/kinship/en.js @@ -0,0 +1,46 @@ +import { kinshipFacts } from './facts.js' + +// Mot genré quand le sexe de la personne nommée est connu, forme neutre ou double sinon +const g = (sex, male, female, neutral) => (sex === 'male' ? male : sex === 'female' ? female : neutral) + +export default function nameEn(path) { + const { ups, downs, hasSpouse, sex } = kinshipFacts(path) + + // « Spouse » reste neutre : husband et wife supposeraient un mariage + if (hasSpouse && ups === 0 && downs === 0) return 'Spouse' + + if (hasSpouse) { + // L'anglais distingue l'ordre : le parent du conjoint (in-law) n'est pas le conjoint du parent (step) + const viaSpouseFirst = path.steps[0].move === 'spouse' + if (ups === 1 && downs === 0) return viaSpouseFirst ? g(sex, 'Father-in-law', 'Mother-in-law', 'Parent-in-law') : g(sex, 'Stepfather', 'Stepmother', 'Stepparent') + if (ups === 0 && downs === 1) return viaSpouseFirst ? g(sex, 'Stepson', 'Stepdaughter', 'Stepchild') : g(sex, 'Son-in-law', 'Daughter-in-law', 'Child-in-law') + if (ups === 1 && downs === 1) return g(sex, 'Brother-in-law', 'Sister-in-law', 'Brother/sister-in-law') + } + + let label + if (downs === 0 && ups > 0) { + if (ups === 1) label = g(sex, 'Father', 'Mother', 'Parent') + else if (ups === 2) label = g(sex, 'Grandfather', 'Grandmother', 'Grandparent') + else if (ups === 3) label = g(sex, 'Great-grandfather', 'Great-grandmother', 'Great-grandparent') + else label = `Ancestor, ${ups} generations up` + } else if (ups === 0 && downs > 0) { + if (downs === 1) label = g(sex, 'Son', 'Daughter', 'Child') + else if (downs === 2) label = g(sex, 'Grandson', 'Granddaughter', 'Grandchild') + else if (downs === 3) label = g(sex, 'Great-grandson', 'Great-granddaughter', 'Great-grandchild') + else label = `Descendant, ${downs} generations down` + } else if (ups === 1 && downs === 1) label = g(sex, 'Brother', 'Sister', 'Sibling') + else if (ups === 2 && downs === 1) label = g(sex, 'Uncle', 'Aunt', 'Uncle/Aunt') + else if (ups === 3 && downs === 1) label = g(sex, 'Great-uncle', 'Great-aunt', 'Great-uncle/aunt') + else if (ups === 1 && downs === 2) label = g(sex, 'Nephew', 'Niece', 'Nephew/Niece') + else if (ups === 1 && downs === 3) label = g(sex, 'Grandnephew', 'Grandniece', 'Grandnephew/niece') + else if (ups >= 2 && downs >= 2) { + const degree = Math.min(ups, downs) - 1 + const removed = Math.abs(ups - downs) + if (degree === 1 && removed === 0) label = 'First cousin' + else if (degree === 1) label = 'First cousin once removed' + else if (removed === 0) label = `Cousin, degree ${degree}` + else label = 'Distant cousin' + } else label = `${ups + downs} steps apart` + + return hasSpouse ? `${label} by marriage` : label +} diff --git a/frontend/src/utils/kinship/facts.js b/frontend/src/utils/kinship/facts.js new file mode 100644 index 0000000..7f62274 --- /dev/null +++ b/frontend/src/utils/kinship/facts.js @@ -0,0 +1,23 @@ +// Sexe d'une personne tel que l'appli le lit : 'male', 'female' ou null (non précisé). +// L'import GEDCOM écrit 'male'/'female' ; d'anciennes données ont pu garder 'M'/'F'. +export function normalizeSex(value) { + const key = String(value ?? '').trim().toLowerCase() + if (key === 'male' || key === 'm') return 'male' + if (key === 'female' || key === 'f') return 'female' + return null +} + +// Ce que le français et l'anglais lisent d'un chemin : générations montées et descendues +// (un frère ou une sœur compte une montée et une descente), passage par un conjoint, et sexe +// de la personne nommée, au bout du chemin. +export function kinshipFacts(path) { + let ups = 0, downs = 0, hasSpouse = false + for (const { move } of path.steps) { + if (move === 'up') ups++ + else if (move === 'down') downs++ + else if (move === 'sibling') { ups++; downs++ } + else if (move === 'spouse') hasSpouse = true + } + const target = path.steps[path.steps.length - 1]?.person + return { ups, downs, hasSpouse, sex: normalizeSex(target?.sex) } +} diff --git a/frontend/src/utils/kinship/fr.js b/frontend/src/utils/kinship/fr.js new file mode 100644 index 0000000..6ece884 --- /dev/null +++ b/frontend/src/utils/kinship/fr.js @@ -0,0 +1,43 @@ +import { kinshipFacts } from './facts.js' + +// Mot genré quand le sexe de la personne nommée est connu, forme double sinon +const g = (sex, male, female, both) => (sex === 'male' ? male : sex === 'female' ? female : both) + +export default function nameFr(path) { + const { ups, downs, hasSpouse, sex } = kinshipFacts(path) + + if (hasSpouse && ups === 0 && downs === 0) return g(sex, 'Conjoint', 'Conjointe', 'Conjoint(e)') + + if (hasSpouse) { + if (ups === 1 && downs === 0) return g(sex, 'Beau-père', 'Belle-mère', 'Beau-parent') + if (ups === 0 && downs === 1) return g(sex, 'Beau-fils', 'Belle-fille', 'Bel-enfant') + if (ups === 1 && downs === 1) return g(sex, 'Beau-frère', 'Belle-sœur', 'Beau-frère/sœur') + } + + let label + if (downs === 0 && ups > 0) { + if (ups === 1) label = g(sex, 'Père', 'Mère', 'Parent') + else if (ups === 2) label = g(sex, 'Grand-père', 'Grand-mère', 'Grand-parent') + else if (ups === 3) label = g(sex, 'Arrière-grand-père', 'Arrière-grand-mère', 'Arrière-grand-parent') + else label = `${g(sex, 'Aïeul', 'Aïeule', 'Aïeul(e)')} ${ups}e gén.` + } else if (ups === 0 && downs > 0) { + if (downs === 1) label = g(sex, 'Fils', 'Fille', 'Enfant') + else if (downs === 2) label = g(sex, 'Petit-fils', 'Petite-fille', 'Petit-enfant') + else if (downs === 3) label = g(sex, 'Arrière-petit-fils', 'Arrière-petite-fille', 'Arrière-petit-enfant') + else label = `${g(sex, 'Descendant', 'Descendante', 'Descendant(e)')} ${downs}e gén.` + } else if (ups === 1 && downs === 1) label = g(sex, 'Frère', 'Sœur', 'Frère/Sœur') + else if (ups === 2 && downs === 1) label = g(sex, 'Oncle', 'Tante', 'Oncle/Tante') + else if (ups === 3 && downs === 1) label = g(sex, 'Grand-oncle', 'Grand-tante', 'Grand-oncle/tante') + else if (ups === 1 && downs === 2) label = g(sex, 'Neveu', 'Nièce', 'Neveu/Nièce') + else if (ups === 1 && downs === 3) label = g(sex, 'Petit-neveu', 'Petite-nièce', 'Petit-neveu/nièce') + else if (ups >= 2 && downs >= 2) { + const degree = Math.min(ups, downs) - 1 + const removed = Math.abs(ups - downs) + if (degree === 1 && removed === 0) label = g(sex, 'Cousin germain', 'Cousine germaine', 'Cousin(e) germain(e)') + else if (degree === 1) label = g(sex, 'Petit-cousin', 'Petite-cousine', 'Petit-cousin(e)') + else if (removed === 0) label = `${g(sex, 'Cousin', 'Cousine', 'Cousin(e)')} ${degree}e degré` + else label = g(sex, 'Cousin éloigné', 'Cousine éloignée', 'Cousin(e) éloigné(e)') + } else label = `${ups + downs} liens` + + return hasSpouse ? `${label} par alliance` : label +} diff --git a/frontend/src/utils/kinship/index.js b/frontend/src/utils/kinship/index.js new file mode 100644 index 0000000..ba9d5db --- /dev/null +++ b/frontend/src/utils/kinship/index.js @@ -0,0 +1,24 @@ +// Nom d'un lien de parenté, une fonction de nommage par langue (issue #114). +// +// Le chemin arrive complet : la personne de départ, puis chaque pas avec la personne atteinte. +// { from: { id, sex, birthYear }, steps: [{ move, person: { id, sex, birthYear } }] } +// move vaut 'up' (vers un parent), 'down' (vers un enfant), 'sibling' (vers un frère ou une sœur) +// ou 'spouse' (vers un conjoint). sex vaut 'male', 'female' ou null (non précisé). +// +// Chaque langue lit ce dont elle a besoin. Le français et l'anglais comptent les générations et +// regardent le sexe de la personne nommée (kinshipFacts). Une langue qui distingue le côté paternel +// ou maternel lit le sexe du premier parent du chemin (steps[0].person quand steps[0].move === 'up'), +// et les aînés et cadets se lisent sur les années de naissance de deux personnes du chemin. +// Brancher une langue : écrire ./.js sur le modèle de ./fr.js et l'ajouter à NAMERS. +import { locale } from '../../i18n/index.js' +import nameFr from './fr.js' +import nameEn from './en.js' + +export { kinshipFacts, normalizeSex } from './facts.js' + +const NAMERS = { fr: nameFr, en: nameEn } + +export function nameKinship(path, lang = locale) { + if (!path?.steps?.length) return '' + return (NAMERS[lang] || nameEn)(path) +} diff --git a/frontend/src/utils/kinship/kinship.test.js b/frontend/src/utils/kinship/kinship.test.js new file mode 100644 index 0000000..66143a3 --- /dev/null +++ b/frontend/src/utils/kinship/kinship.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { kinshipFacts, nameKinship, normalizeSex } from './index.js' +import { computeRelationshipLabel, kinshipPath } from '../../components/galaxy/pathfinding.js' + +// Chemin écrit à la main : moves = suite de pas, sex = sexe de la personne au bout +const path = (moves, sex = null, extra = {}) => ({ + from: { id: 'moi', sex: null, birthYear: null }, + steps: moves.map((move, i) => ({ move, person: { id: `p${i}`, sex: i === moves.length - 1 ? sex : null, birthYear: null, ...(extra[i] || {}) } })), +}) + +const both = (moves) => ['male', 'female', null].map((sex) => nameKinship(path(moves, sex), 'fr')) +const bothEn = (moves) => ['male', 'female', null].map((sex) => nameKinship(path(moves, sex), 'en')) + +describe('nommage français', () => { + it('genre le mot quand le sexe est connu, garde la forme double sinon', () => { + expect(both(['up'])).toEqual(['Père', 'Mère', 'Parent']) + expect(both(['down'])).toEqual(['Fils', 'Fille', 'Enfant']) + expect(both(['sibling'])).toEqual(['Frère', 'Sœur', 'Frère/Sœur']) + expect(both(['up', 'up'])).toEqual(['Grand-père', 'Grand-mère', 'Grand-parent']) + expect(both(['down', 'down'])).toEqual(['Petit-fils', 'Petite-fille', 'Petit-enfant']) + expect(both(['up', 'up', 'up'])).toEqual(['Arrière-grand-père', 'Arrière-grand-mère', 'Arrière-grand-parent']) + expect(both(['down', 'down', 'down'])).toEqual(['Arrière-petit-fils', 'Arrière-petite-fille', 'Arrière-petit-enfant']) + expect(both(['up', 'sibling'])).toEqual(['Oncle', 'Tante', 'Oncle/Tante']) + expect(both(['up', 'up', 'sibling'])).toEqual(['Grand-oncle', 'Grand-tante', 'Grand-oncle/tante']) + expect(both(['sibling', 'down'])).toEqual(['Neveu', 'Nièce', 'Neveu/Nièce']) + expect(both(['sibling', 'down', 'down'])).toEqual(['Petit-neveu', 'Petite-nièce', 'Petit-neveu/nièce']) + expect(both(['up', 'sibling', 'down'])).toEqual(['Cousin germain', 'Cousine germaine', 'Cousin(e) germain(e)']) + expect(both(['up', 'up', 'sibling', 'down'])).toEqual(['Petit-cousin', 'Petite-cousine', 'Petit-cousin(e)']) + expect(both(['up', 'up', 'sibling', 'down', 'down'])).toEqual(['Cousin 2e degré', 'Cousine 2e degré', 'Cousin(e) 2e degré']) + expect(both(['up', 'up', 'up', 'sibling', 'down', 'down'])).toEqual(['Cousin éloigné', 'Cousine éloignée', 'Cousin(e) éloigné(e)']) + expect(both(['up', 'up', 'up', 'up'])).toEqual(['Aïeul 4e gén.', 'Aïeule 4e gén.', 'Aïeul(e) 4e gén.']) + }) + + it('alliances', () => { + expect(both(['spouse'])).toEqual(['Conjoint', 'Conjointe', 'Conjoint(e)']) + expect(both(['spouse', 'up'])).toEqual(['Beau-père', 'Belle-mère', 'Beau-parent']) + expect(both(['down', 'spouse'])).toEqual(['Beau-fils', 'Belle-fille', 'Bel-enfant']) + expect(both(['spouse', 'sibling'])).toEqual(['Beau-frère', 'Belle-sœur', 'Beau-frère/sœur']) + expect(both(['spouse', 'up', 'sibling'])).toEqual(['Oncle par alliance', 'Tante par alliance', 'Oncle/Tante par alliance']) + }) + + it('le sexe des personnes du milieu ne change pas le mot (seule la personne nommée compte)', () => { + const p = path(['up', 'sibling'], 'female', { 0: { sex: 'male' } }) + expect(nameKinship(p, 'fr')).toBe('Tante') + }) +}) + +describe('nommage anglais', () => { + it('genre le mot quand le sexe est connu', () => { + expect(bothEn(['up'])).toEqual(['Father', 'Mother', 'Parent']) + expect(bothEn(['down', 'down'])).toEqual(['Grandson', 'Granddaughter', 'Grandchild']) + expect(bothEn(['sibling'])).toEqual(['Brother', 'Sister', 'Sibling']) + expect(bothEn(['up', 'sibling'])).toEqual(['Uncle', 'Aunt', 'Uncle/Aunt']) + expect(bothEn(['sibling', 'down'])).toEqual(['Nephew', 'Niece', 'Nephew/Niece']) + expect(bothEn(['spouse', 'up'])).toEqual(['Father-in-law', 'Mother-in-law', 'Parent-in-law']) + expect(bothEn(['up', 'spouse'])).toEqual(['Stepfather', 'Stepmother', 'Stepparent']) + expect(bothEn(['spouse', 'down'])).toEqual(['Stepson', 'Stepdaughter', 'Stepchild']) + expect(bothEn(['down', 'spouse'])).toEqual(['Son-in-law', 'Daughter-in-law', 'Child-in-law']) + expect(bothEn(['spouse', 'sibling'])).toEqual(['Brother-in-law', 'Sister-in-law', 'Brother/sister-in-law']) + expect(bothEn(['spouse'])).toEqual(['Spouse', 'Spouse', 'Spouse']) + expect(bothEn(['up', 'sibling', 'down'])).toEqual(['First cousin', 'First cousin', 'First cousin']) + }) + + it('une langue inconnue retombe sur l’anglais', () => { + expect(nameKinship(path(['up'], 'female'), 'xx')).toBe('Mother') + }) +}) + +describe('chemin de parenté depuis le chemin BFS', () => { + // moi → union de mes parents → mon père → union des grands-parents → ma tante + const edgePath = [ + { type: 'filiation', id: 'e1' }, + { type: 'couple', id: 'u1' }, + { type: 'filiation', id: 'e2' }, + { type: 'filiation', id: 'e3' }, + ] + const nodePath = ['p-1', 'u-1', 'p-2', 'u-2', 'p-3'] + const people = { 'p-1': { id: 1, sex: 'female', birthYear: 1990 }, 'p-2': { id: 2, sex: 'M', birthYear: 1960 }, 'p-3': { id: 3, sex: 'F', birthYear: 1955 } } + + it('garde chaque pas et la personne atteinte, pour les langues qui en ont besoin', () => { + const k = kinshipPath(edgePath, nodePath, (id) => people[id]) + expect(k.from).toEqual({ id: 1, sex: 'female', birthYear: 1990 }) + expect(k.steps).toEqual([ + { move: 'up', person: { id: 2, sex: 'male', birthYear: 1960 } }, + { move: 'sibling', person: { id: 3, sex: 'female', birthYear: 1955 } }, + ]) + expect(kinshipFacts(k)).toEqual({ ups: 2, downs: 1, hasSpouse: false, sex: 'female' }) + }) + + it('nomme avec le sexe de la personne au bout', () => { + expect(computeRelationshipLabel(edgePath, nodePath, (id) => people[id])).toBe('Tante') + expect(computeRelationshipLabel(edgePath)).toBe('Oncle/Tante') + }) +}) + +describe('normalizeSex', () => { + it('lit les anciennes valeurs M/F et le reste en non précisé', () => { + expect(['male', 'M', 'female', 'f', null, '', 'U'].map(normalizeSex)).toEqual(['male', 'male', 'female', 'female', null, null, null]) + }) +}) diff --git a/frontend/src/utils/personSex.js b/frontend/src/utils/personSex.js new file mode 100644 index 0000000..27c3b34 --- /dev/null +++ b/frontend/src/utils/personSex.js @@ -0,0 +1,10 @@ +import { t } from '../i18n/index.js' + +export { normalizeSex } from './kinship/facts.js' + +// Choix proposés à la saisie du sexe d'une personne (issue #114) : null = non précisé +export const SEX_CHOICES = [ + { value: 'female', label: t('Femme') }, + { value: 'male', label: t('Homme') }, + { value: null, label: t('Non précisé') }, +]