feat(personnes): quatrième choix « Autre » (SEX X), case du prénom inconnu sur une ligne (#114)

- Sexe : Femme, Homme, Autre, Non précisé. Autre est enregistré « other »,
  refusé en 400 comme toute valeur inconnue, et prend les formes neutres ou
  doubles pour nommer un lien.
- GEDCOM : import SEX X (GEDCOM 7) → other, SEX U → non précisé ; export
  other → SEX X, non précisé sans ligne SEX. Aller-retour testé.
- « Ajouter quelqu'un » : « Je ne connais pas son prénom » sort de la colonne
  Prénom et tient sur une ligne (sous les deux champs sur ordinateur, sous
  Prénom sur téléphone) ; les quatre choix du sexe tiennent sur une ligne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-20 15:13:12 +02:00
1 parent eaecac8fa5
commit 7a43fe4aaf
11 files changed
+109 -27

No files matched your search

+7 -5
View File
@@ -1,12 +1,13 @@
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'
// 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)
@@ -15,6 +16,7 @@ export function parseSex(value: string | null): PersonSex | null | undefined {
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
}
@@ -24,7 +26,7 @@ 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
// 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)
+1
View File
@@ -459,6 +459,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
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')
+59 -1
View File
@@ -893,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()
@@ -1150,7 +1205,7 @@ 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 () => {
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 })
@@ -1161,6 +1216,9 @@ describe('Sexe d’une personne en écriture directe (issue #114)', () => {
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 })
+4 -1
View File
@@ -2,7 +2,7 @@ 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
// 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')
@@ -14,6 +14,9 @@ describe('sexe d’une personne', () => {
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()
})
@@ -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,9 @@
/* 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); }
/* « Je ne connais pas son prénom » : une ligne, sous Prénom et Nom (ordinateur), sous Prénom (téléphone, ordre du DOM) */
.apn-name-row > .apn-unknown { grid-column: 1 / -1; grid-row: 2; margin-top: -6px; white-space: nowrap; justify-self: start; }
@media (max-width: 480px) { .apn-name-row > .apn-unknown { 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; }
+17 -16
View File
@@ -200,7 +200,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<form className="apn-form" onSubmit={handleSubmit}>
<div className="apn-scroll">
<div className="pz-row2 apn-row">
<div className="pz-row2 apn-row apn-name-row">
<div className="pz-field">
<label htmlFor="ap-firstName">{t('Prénom')}</label>
<input
@@ -215,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) && (
<label className="apn-deceased">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setTouched({})
}}
/>
{t('Je ne connais pas son prénom')}
</label>
)}
</div>
{/* 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) && (
<label className="apn-deceased apn-unknown">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setTouched({})
}}
/>
{t('Je ne connais pas son prénom')}
</label>
)}
<div className="pz-field">
<label htmlFor="ap-lastName">{t('Nom')}</label>
<input
@@ -277,7 +278,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<div className="pz-field">
<span className="pz-label" id="ap-sex-label">{t('Sexe')} <span className="apn-optional">{t('facultatif')}</span></span>
<div className="pz-tabs apn-types" role="radiogroup" aria-labelledby="ap-sex-label">
<div className="pz-tabs apn-types apn-sex" role="radiogroup" aria-labelledby="ap-sex-label">
{SEX_CHOICES.map(({ value, label }) => (
<button
key={label}
+1
View File
@@ -57,6 +57,7 @@ export default {
'Sexe': 'Sex',
'Femme': 'Female',
'Homme': 'Male',
'Autre': 'Other',
'Non précisé': 'Not specified',
'Adopté': 'Adopted',
'Adoptée': 'Adopted',
+4 -2
View File
@@ -1,9 +1,11 @@
// 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'.
// Sexe d'une personne tel que l'appli le lit : 'male', 'female', 'other' (autre) ou null (non précisé).
// L'import GEDCOM écrit 'male'/'female'/'other' ; d'anciennes données ont pu garder 'M'/'F'.
// Pour nommer un lien, 'other' prend les formes neutres ou doubles, comme null.
export function normalizeSex(value) {
const key = String(value ?? '').trim().toLowerCase()
if (key === 'male' || key === 'm') return 'male'
if (key === 'female' || key === 'f') return 'female'
if (key === 'other' || key === 'x') return 'other'
return null
}
+1 -1
View File
@@ -3,7 +3,7 @@
// 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é).
// ou 'spouse' (vers un conjoint). sex vaut 'male', 'female', 'other' (autre) 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
+9 -1
View File
@@ -39,6 +39,14 @@ describe('nommage français', () => {
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')
@@ -95,6 +103,6 @@ describe('chemin de parenté depuis le chemin BFS', () => {
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])
expect(['male', 'M', 'female', 'f', 'other', 'X', null, '', 'U'].map(normalizeSex)).toEqual(['male', 'male', 'female', 'female', 'other', 'other', null, null, null])
})
})
+1
View File
@@ -6,5 +6,6 @@ export { normalizeSex } from './kinship/facts.js'
export const SEX_CHOICES = [
{ value: 'female', label: t('Femme') },
{ value: 'male', label: t('Homme') },
{ value: 'other', label: t('Autre') },
{ value: null, label: t('Non précisé') },
]