diff --git a/backend/src/lib/person-sex.ts b/backend/src/lib/person-sex.ts new file mode 100644 index 0000000..20a9a45 --- /dev/null +++ b/backend/src/lib/person-sex.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +// Sexe d'une personne : 'male', 'female', 'other' (autre), ou null (non précisé). L'import GEDCOM écrit +// 'male'/'female'/'other' (SEX X de GEDCOM 7), d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est +// ramené à ces valeurs, à l'écriture comme à la lecture, sans migration des lignes existantes. +export type PersonSex = 'male' | 'female' | 'other' + +const MALE = new Set(['male', 'm', 'homme', 'h']) +const FEMALE = new Set(['female', 'f', 'femme']) +const OTHER = new Set(['other', 'x', 'autre']) +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 (OTHER.has(key)) return 'other' + 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/other +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..02c691d 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,10 @@ 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') + else if (sex === 'other') lines.push('1 SEX X') const personEvents = eventsByPerson.get(person.id) ?? [] const birthDetails = personEvents.find((event) => event.type === 'birth') @@ -518,9 +521,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 +678,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..02ea53f 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) @@ -891,6 +893,61 @@ describe('Import GEDCOM', () => { }) }) +describe('Sexe dans le GEDCOM (issue #114)', () => { + const importer = async (app: FastifyInstance, content: string) => { + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + prismaMock.person.findMany.mockResolvedValue([]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` })) + prismaMock.person.create.mockClear() + const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } }) + expect(response.statusCode).toBe(200) + return Object.fromEntries(prismaMock.person.create.mock.calls.map((call: [{ data: { firstName: string; sex: string | null } }]) => [call[0].data.firstName, call[0].data.sex])) + } + + it('import : M, F, X (GEDCOM 7), U et absent donnent male, female, other, null, null', async () => { + const app = createApp() + const content = [ + '0 HEAD', + '0 @I1@ INDI', '1 NAME Jean /Martin/', '1 SEX M', + '0 @I2@ INDI', '1 NAME Marie /Martin/', '1 SEX F', + '0 @I3@ INDI', '1 NAME Camille /Martin/', '1 SEX X', + '0 @I4@ INDI', '1 NAME Dominique /Martin/', '1 SEX U', + '0 @I5@ INDI', '1 NAME Claude /Martin/', + '0 TRLR', + ].join('\n') + expect(await importer(app, content)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Dominique: null, Claude: null }) + await app.close() + }) + + it('aller-retour : other sort en SEX X, non précisé sans ligne SEX, et tout se relit à l’identique', async () => { + const app = createApp() + await app.ready() + const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() }) + const base = { lastName: 'Martin', birthDate: null, birthDatePrecision: null, birthPlace: null, birthDateEnd: null, deathDateEnd: null, deathDatePrecision: null, deathPlace: null, nickname: null, notes: null, profession: null, deathDate: null } + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Martin', rootPersonId: null }) + prismaMock.person.findMany.mockResolvedValue([ + { ...base, id: 'p-1', firstName: 'Jean', sex: 'male' }, + { ...base, id: 'p-2', firstName: 'Marie', sex: 'female' }, + { ...base, id: 'p-3', firstName: 'Camille', sex: 'other' }, + { ...base, id: 'p-4', firstName: 'Claude', sex: null }, + ]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.lifeEvent.findMany.mockResolvedValue([]) + + const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + const text = response.body.replace(/\r\n/g, '\n') + expect(text).toContain('1 NAME Camille /Martin/\n1 SEX X\n') + expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/) + + expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Claude: null }) + await app.close() + }) +}) + describe('Visit log', () => { it('refuses the visit log to a non-admin member', async () => { const app = createApp() @@ -1147,6 +1204,31 @@ describe('Surnom (nickname) en écriture directe', () => { }) }) +describe('Sexe d’une personne en écriture directe (issue #114)', () => { + it('PATCH ramène le sexe à male / female / other, 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: 'other' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'other' }) + + 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..51c27bd --- /dev/null +++ b/backend/test/person-sex.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { parseSex, sexOrNull, sexSchema } from '../src/lib/person-sex' + +// Sexe d'une personne (issue #114) : male, female, other 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('other')).toBe('other') + expect(parseSex('X')).toBe('other') + expect(parseSex('Autre')).toBe('other') + 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 d8ac038..f8c5e7c 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 @@ -1077,6 +1079,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, @@ -1157,6 +1160,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), @@ -1184,6 +1188,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), @@ -1315,6 +1320,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 @@ -1591,6 +1597,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 @@ -1629,6 +1636,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.css b/frontend/src/components/AddPersonPanel.css index 3d1689e..f4802c7 100644 --- a/frontend/src/components/AddPersonPanel.css +++ b/frontend/src/components/AddPersonPanel.css @@ -43,6 +43,8 @@ .apn-section { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--pz-line-soft); } .apn-types .pz-tab { font-size: 13px; padding: 0 6px; } +/* Sexe : quatre choix, chacun sur une ligne même dans la colonne étroite du panneau */ +.apn-sex .pz-tab { white-space: nowrap; padding: 0 4px; } .apn-relation { position: relative; } .apn-search { position: relative; display: flex; align-items: center; } @@ -108,6 +110,10 @@ /* Décédé(e) sans date connue (issue #77) */ .apn-deceased { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; } .apn-deceased input { width: 15px; height: 15px; accent-color: var(--pz-a500); } +/* Case sous une ligne de deux champs (« Je ne connais pas son prénom », « Décédé(e), date inconnue ») : sur une ligne, + sous les deux champs sur ordinateur, sous son champ sur téléphone (ordre du DOM) */ +.apn-row > .apn-row-check { grid-column: 1 / -1; grid-row: 2; margin-top: -6px; white-space: nowrap; justify-self: start; } +@media (max-width: 480px) { .apn-row > .apn-row-check { grid-row: auto; } } /* Colonnes alignées en haut : la case « décédé(e) » sous le décès ne décale pas la naissance */ .apn-row { align-items: start; } diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index 36e5293..ae448b5 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, @@ -211,21 +215,22 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on disabled={unknownFirstName} aria-invalid={touched.firstName && !unknownFirstName && !firstName.trim()} /> - {/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher) */} - {(!firstName.trim() || unknownFirstName) && ( - - )} + {/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher). + Hors de la colonne Prénom pour tenir sur une ligne : sous les deux champs sur ordinateur, sous Prénom sur téléphone */} + {(!firstName.trim() || unknownFirstName) && ( + + )}
.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..b73daa4
--- /dev/null
+++ b/frontend/src/utils/kinship/kinship.test.js
@@ -0,0 +1,108 @@
+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('« Autre » prend les formes neutres ou doubles, comme non précisé', () => {
+ const autre = (moves, lang) => nameKinship(path(moves, 'other'), lang)
+ expect(['up', 'down', 'sibling', 'spouse'].map((m) => autre([m], 'fr'))).toEqual(['Parent', 'Enfant', 'Frère/Sœur', 'Conjoint(e)'])
+ expect(autre(['up', 'sibling', 'down'], 'fr')).toBe('Cousin(e) germain(e)')
+ expect(autre(['up', 'spouse'], 'fr')).toBe('Beau-parent')
+ expect(['up', 'down', 'sibling', 'up,sibling', 'up,spouse'].map((m) => autre(m.split(','), 'en'))).toEqual(['Parent', 'Child', 'Sibling', 'Uncle/Aunt', 'Stepparent'])
+ })
+
+ 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', 'other', 'X', null, '', 'U'].map(normalizeSex)).toEqual(['male', 'male', 'female', 'female', 'other', 'other', null, null, null])
+ })
+})
diff --git a/frontend/src/utils/personSex.js b/frontend/src/utils/personSex.js
new file mode 100644
index 0000000..5210d60
--- /dev/null
+++ b/frontend/src/utils/personSex.js
@@ -0,0 +1,19 @@
+import { t } from '../i18n/index.js'
+
+import { normalizeSex } from './kinship/facts.js'
+
+export { normalizeSex }
+
+// Choix proposés à la saisie du sexe d'une personne (issue #114, tranché par Patch le 20/10) : trois boutons à
+// bascule. Rien de choisi = non précisé (null), la valeur par défaut ; un second clic sur le choix actif y revient.
+export const SEX_CHOICES = [
+ { value: 'female', label: t('Femme') },
+ { value: 'male', label: t('Homme') },
+ { value: 'other', label: t('Autre') },
+]
+
+// Libellé d'une valeur enregistrée, « Non précisé » compris (relecture des propositions)
+export function sexLabel(value) {
+ const sex = normalizeSex(value)
+ return SEX_CHOICES.find((choice) => choice.value === sex)?.label ?? t('Non précisé')
+}
diff --git a/frontend/src/utils/personSex.test.js b/frontend/src/utils/personSex.test.js
new file mode 100644
index 0000000..6236682
--- /dev/null
+++ b/frontend/src/utils/personSex.test.js
@@ -0,0 +1,12 @@
+import { describe, it, expect } from 'vitest'
+import { SEX_CHOICES, sexLabel } from './personSex.js'
+
+describe('saisie du sexe (issue #114)', () => {
+ it('trois choix, non précisé par défaut quand rien n’est choisi', () => {
+ expect(SEX_CHOICES.map((c) => c.value)).toEqual(['female', 'male', 'other'])
+ })
+
+ it('libellé d’une valeur enregistrée, « Non précisé » si vide', () => {
+ expect([null, '', 'F', 'male', 'other'].map(sexLabel)).toEqual(['Non précisé', 'Non précisé', 'Femme', 'Homme', 'Autre'])
+ })
+})