Merge pull request #85 from IliesAllali/feat/surnoms

Surnoms sous le nom, dans l'arbre et la fiche
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-18 20:53:55 +02:00
commit d0be3e9165
20 files changed
+242 -3

No files matched your search

+5
View File
@@ -71,6 +71,7 @@ const personCreateSchema = z.object({
firstName: z.string().min(1).max(120),
lastName: z.string().min(1).max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
@@ -87,6 +88,7 @@ const personUpdateSchema = z
firstName: z.string().min(1).max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
@@ -378,6 +380,7 @@ async function applyContributionChange(
firstName: parsed.data.firstName.trim(),
lastName: parsed.data.lastName.trim(),
birthName: toTrimmedStringOrNull(parsed.data.birthName),
nickname: toTrimmedStringOrNull(parsed.data.nickname),
birthDate: toDateOrNull(parsed.data.birthDate),
deathDate: toDateOrNull(parsed.data.deathDate),
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
@@ -435,6 +438,7 @@ async function applyContributionChange(
firstName: parsed.data.firstName !== undefined ? parsed.data.firstName.trim() : undefined,
lastName: parsed.data.lastName !== undefined ? parsed.data.lastName.trim() : undefined,
birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined,
nickname: parsed.data.nickname !== undefined ? toTrimmedStringOrNull(parsed.data.nickname) : undefined,
birthDate: nextDates.birthDate,
deathDate: nextDates.deathDate,
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
@@ -832,6 +836,7 @@ async function snapshotEntity(
firstName: true,
lastName: true,
birthName: true,
nickname: true,
birthDate: true,
deathDate: true,
isDeceased: true,
+8
View File
@@ -87,6 +87,7 @@ const createPersonSchema = z.object({
firstName: z.string().min(1).max(120),
lastName: z.string().min(1).max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
@@ -103,6 +104,7 @@ const patchPersonSchema = z
firstName: z.string().min(1).max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
@@ -1310,6 +1312,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
firstName: payload.data.firstName.trim(),
lastName: payload.data.lastName.trim(),
birthName: toTrimmedStringOrNull(payload.data.birthName),
nickname: toTrimmedStringOrNull(payload.data.nickname),
birthDate: toDateOrNull(payload.data.birthDate),
deathDate: toDateOrNull(payload.data.deathDate),
// Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date
@@ -1368,6 +1371,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
firstName?: string
lastName?: string
birthName?: string | null
nickname?: string | null
birthDate?: Date | null
deathDate?: Date | null
isDeceased?: boolean
@@ -1392,6 +1396,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.birthName = toTrimmedStringOrNull(payload.data.birthName)
}
if (payload.data.nickname !== undefined) {
data.nickname = toTrimmedStringOrNull(payload.data.nickname)
}
if (payload.data.birthDate !== undefined) {
data.birthDate = toDateOrNull(payload.data.birthDate)
}
+2
View File
@@ -9,6 +9,7 @@ export type GraphPayload = {
firstName: string
lastName: string
birthName: string | null
nickname: string | null
birthDate: string | null
deathDate: string | null
isDeceased: boolean
@@ -84,6 +85,7 @@ export function buildGraphPayload(
firstName: person.firstName,
lastName: person.lastName,
birthName: person.birthName,
nickname: person.nickname,
birthDate: formatDate(person.birthDate),
deathDate: formatDate(person.deathDate),
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
+23
View File
@@ -985,3 +985,26 @@ describe('Relationship guardrails', () => {
await app.close()
})
})
describe('Surnom (nickname) en écriture directe', () => {
it('PATCH accepte le surnom, le coupe, et vide = null', 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({ nickname: ' Loulou ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' })
expect((await patch({ nickname: ' ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: null })
await app.close()
})
it('refuse un surnom de plus de 120 caractères', async () => {
const app = createApp()
const token = await createUserToken(app)
const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload: { nickname: 'x'.repeat(121) } })
expect(res.statusCode).toBe(400)
await app.close()
})
})
+72
View File
@@ -734,3 +734,75 @@ describe('Journey 4 — Admin reviews a contribution session', () => {
await app.close()
})
})
describe('Surnom (nickname) proposé par un contributeur', () => {
it('soumis en attente, puis appliqué par le propriétaire avec la valeur nettoyée', async () => {
const app = createApp()
await app.ready()
const contributorToken = (app as any).jwt.sign({
kind: 'tree_access',
sub: 'tree:tree-1:contributor',
treeId: 'tree-1',
role: 'contributor',
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
})
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', deletedAt: null, settings: { contributorPolicy: 'pending' } })
const submitRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions',
headers: { authorization: `Bearer ${contributorToken}` },
payload: {
title: 'Surnom de Robert',
submittedByLabel: 'Jeanette Martin',
changes: [
{
entityType: 'person',
action: 'update',
entityId: 'person-1',
before: { nickname: null },
after: { nickname: ' Loulou ' },
},
],
},
})
expect(submitRes.statusCode).toBe(201)
expect(submitRes.json().session.status).toBe('pending')
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-n',
treeId: 'tree-1',
status: 'pending',
title: 'Surnom de Robert',
changes: [
{
id: 'change-n',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { nickname: null },
afterJson: { nickname: ' Loulou ' },
conflictState: 'none',
},
],
})
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Robert', lastName: 'Dupont', nickname: null, deletedAt: null })
const reviewRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-n/review',
headers: { authorization: `Bearer ${adminToken}` },
payload: { decision: 'approved' },
})
expect(reviewRes.statusCode).toBe(200)
expect(reviewRes.json().approvedCount).toBe(1)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' })
await app.close()
})
})
+2
View File
@@ -13,6 +13,7 @@ describe('buildGraphPayload', () => {
firstName: 'Zoe',
lastName: 'Martin',
birthName: 'Durand',
nickname: 'Zozo',
birthDate: new Date('1994-06-02T00:00:00.000Z'),
deathDate: null,
sex: null,
@@ -115,6 +116,7 @@ describe('buildGraphPayload', () => {
expect(graph.persons[0].id).toBe('p-2')
expect(graph.persons[0].birthDate).toBe('1994-06-02')
expect(graph.persons[0].birthName).toBe('Durand')
expect(graph.persons[0].nickname).toBe('Zozo')
expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar')
// L'identifiant Immich ne part jamais dans le graphe : il ne sert qu'aux administrateurs.
// Seule l'origine du portrait (fichier préfixé « immich- ») est publiée.
+8
View File
@@ -239,6 +239,7 @@ function buildContributionChangesFromDraft(draft = {}) {
firstName: String(person.firstName).trim(),
lastName: String(person.lastName).trim(),
birthName: person.birthName ? String(person.birthName).trim() : null,
nickname: person.nickname ? String(person.nickname).trim() : null,
birthDate: person.birthDate || null,
deathDate: person.deathDate || null,
isDeceased: !person.deathDate && Boolean(person.isDeceased),
@@ -283,6 +284,7 @@ function buildContributionChangesFromDraft(draft = {}) {
if (Object.prototype.hasOwnProperty.call(source, 'firstName')) after.firstName = source.firstName || ''
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, '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
@@ -996,6 +998,7 @@ function App() {
firstName: formData.firstName,
lastName: formData.lastName || '',
birthName: formData.birthName || null,
nickname: formData.nickname || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
birthYear,
@@ -1075,6 +1078,7 @@ function App() {
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName || null,
nickname: formData.nickname || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
@@ -1101,6 +1105,7 @@ function App() {
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName,
nickname: formData.nickname,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
@@ -1230,6 +1235,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(rawChanges, 'firstName')) payload.firstName = rawChanges.firstName || ''
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, '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
@@ -1504,6 +1510,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) localPerson.firstName = changes.firstName || ''
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, 'birthYear')) {
const iso = yearToIsoDate(changes.birthYear)
localPerson.birthDate = iso
@@ -1540,6 +1547,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) payload.firstName = changes.firstName || ''
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, '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
@@ -34,6 +34,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [birthName, setBirthName] = useState('')
const [nickname, setNickname] = useState('')
const [birthDate, setBirthDate] = useState('')
const [deathDate, setDeathDate] = useState('')
// Décédé(e) sans date connue (issue #77)
@@ -91,6 +92,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
setFirstName('')
setLastName('')
setBirthName('')
setNickname('')
setBirthDate('')
setDeathDate('')
setIsDeceased(false)
@@ -139,6 +141,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
firstName: firstName.trim(),
lastName: lastName.trim(),
birthName: birthName.trim() || null,
nickname: nickname.trim() || null,
birthDate: birthDate || null,
deathDate: deathDate || null,
isDeceased: !deathDate && isDeceased,
@@ -222,6 +225,19 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
/>
</div>
<div className="pz-field">
<label htmlFor="ap-nickname">{t('Surnom')} <span className="apn-optional">{t('facultatif')}</span></label>
<input
id="ap-nickname"
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={t('Loulou')}
autoComplete="off"
maxLength={120}
/>
</div>
<div className="pz-row2 apn-row">
<div className="pz-field">
<label htmlFor="ap-birthDate">{t('Naissance')}</label>
@@ -73,6 +73,7 @@ const PERSON_FIELD_LABELS = {
firstName: t('Prénom'),
lastName: t('Nom'),
birthName: t('Nom de naissance'),
nickname: t('Surnom'),
birthDate: t('Date de naissance'),
deathDate: t('Date de décès'),
birthPlace: t('Lieu de naissance'),
+1
View File
@@ -68,6 +68,7 @@
.pc-id { display: grid; gap: 6px; min-width: 0; padding-top: 4px; }
.pc-name { margin: 0; font: 400 28px/1.08 var(--pz-serif); letter-spacing: -.01em; color: var(--pz-ink); text-wrap: balance; }
.pc-name em { font-style: italic; font-weight: 300; }
.pc-nick { margin: 0; font: italic 300 17px/1.3 var(--pz-serif); color: var(--pz-muted); }
.pc-life { margin: 0; font: 400 13.5px/1.4 var(--pz-sans); color: var(--pz-muted); }
.pc-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.pc-chips:empty { display: none; }
+7
View File
@@ -544,6 +544,7 @@ const PersonCard = ({
{person.firstName} <em>{person.lastName}</em>
</h2>
)}
{!editMode && person.nickname && <p className="pc-nick">{person.nickname}</p>}
{!editMode && lifeLine && <p className="pc-life">{lifeLine}</p>}
<div className="pc-chips">
{editMode && <span className="pz-tag pz-tag--accent pz-tag--dot">{t('Édition')}</span>}
@@ -697,6 +698,12 @@ const PersonCard = ({
{renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') })}
</div>
)}
{(person.nickname || editMode) && (
<div className="pc-row">
<span className="pc-label">{t('Surnom')}</span>
{renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') })}
</div>
)}
{editMode && (
<div className="pc-row">
<span className="pc-label">{t('Statut')}</span>
+13 -1
View File
@@ -10,6 +10,10 @@ import {
LOD_SHADOW_MIN_SCALE, LOD_DETAIL_MIN_SCALE, LOD_LABEL_FADE_IN, LOD_LABEL_FADE_OUT, CULL_MARGIN,
} from './constants'
import { getEntranceProgress, drawPartialBezier } from './utils'
import { fitNickname } from '../../utils/nickname.js'
// Largeur maximale du surnom sur le nœud (px canevas) : au-delà, coupé avec …
const NICKNAME_MAX_W = 150
// ============================================================
// Culling : view = { x0, y0, x1, y1, scale } en coordonnées monde
@@ -611,6 +615,14 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
ctx.fillText(nameLabel, 0, 0)
}
// Surnom sous le nom, en italique : les dates descendent pour lui faire place
const hasNickname = withDetail && Boolean(person.nickname)
if (hasNickname) {
ctx.font = 'italic 300 13px "Newsreader", Georgia, serif'
ctx.fillStyle = COLORS.textLight
ctx.fillText(fitNickname(ctx, person.nickname, NICKNAME_MAX_W), 0, 15)
}
if (withDetail) {
ctx.font = '400 11px "DM Sans", system-ui, sans-serif'
ctx.fillStyle = COLORS.textLight
@@ -618,7 +630,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
const years = person.deathYear
? `${birthYearLabel}–${person.deathYear}`
: person.isAlive === false ? `${birthYearLabel}–?` : `${birthYearLabel}`
ctx.fillText(years, 0, 15)
ctx.fillText(years, 0, hasNickname ? 31 : 15)
}
ctx.restore()
+3 -2
View File
@@ -9,8 +9,8 @@ import { buildMediaUrl } from '../utils/mediaToken.js'
// Démo : noms et lieux restent français, les textes descriptifs sont traduits.
export let persons = [
{ id: 1, firstName: 'Jean', sex: 'male', lastName: 'Dupont', birthYear: 1890, deathYear: 1965, isAlive: false, photo: '/demo/jean.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Menuisier'), nationality: t('Française'), note: t('Patriarche de la famille') },
{ id: 2, firstName: 'Marie', sex: 'female', lastName: 'Martin', birthYear: 1895, deathYear: 1970, isAlive: false, photo: '/demo/marie.webp', frameType: 'round', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Couturière'), note: t('Atelier de couture rue du Commerce') },
{ id: 3, firstName: 'Robert', sex: 'male', lastName: 'Dupont', birthYear: 1920, deathYear: 1995, isAlive: false, photo: '/demo/robert.webp', frameType: 'rect', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Instituteur'), note: t("A enseigné 35 ans à l'école communale") },
{ id: 2, firstName: 'Marie', sex: 'female', lastName: 'Martin', nickname: 'Mamie Mimi', birthYear: 1895, deathYear: 1970, isAlive: false, photo: '/demo/marie.webp', frameType: 'round', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Couturière'), note: t('Atelier de couture rue du Commerce') },
{ id: 3, firstName: 'Robert', sex: 'male', lastName: 'Dupont', nickname: 'Loulou', birthYear: 1920, deathYear: 1995, isAlive: false, photo: '/demo/robert.webp', frameType: 'rect', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Instituteur'), note: t("A enseigné 35 ans à l'école communale") },
{ id: 4, firstName: 'André', sex: 'male', lastName: 'Dupont', birthYear: 1923, deathYear: 2000, isAlive: false, photo: '/demo/andre.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Charpentier') },
{ id: 5, firstName: 'Simone', sex: 'female', lastName: 'Petit', birthYear: 1925, deathYear: 2010, isAlive: false, photo: '/demo/simone.webp', frameType: 'round', birthPlace: 'Guéret', region: 'Creuse', profession: t('Infirmière') },
{ id: 9, firstName: 'Isabelle', sex: 'female', lastName: 'Bernard', birthYear: 1928, isAlive: true, photo: '/demo/isabelle.webp', frameType: 'rect', birthPlace: 'Fort-de-France', region: 'Martinique', profession: t('Sage-femme'), note: t('Doyenne de la famille, vit à Brive') },
@@ -103,6 +103,7 @@ function mapRemotePersonToLocal(person, current = null) {
firstName: person?.firstName || current?.firstName || t('Inconnu'),
lastName: person?.lastName || current?.lastName || '',
birthName: person?.birthName ?? current?.birthName ?? null,
nickname: person?.nickname ?? null,
birthYear,
birthDate: person?.birthDate || current?.birthDate || null,
deathDate: person?.deathDate || current?.deathDate || null,
+14
View File
@@ -76,3 +76,17 @@ describe('loadGraphData, union à un seul parent', () => {
expect(getChildren('julien').map((p) => p.id).sort()).toEqual(['hugo', 'lea'])
})
})
describe('loadGraphData, surnom', () => {
it("garde le surnom renvoyé par l'API", () => {
loadGraphData({
persons: [
{ id: 'p-1', firstName: 'Louise', lastName: 'Martin', birthDate: null, deathDate: null, notes: null, nickname: 'Loulou' },
{ id: 'p-2', firstName: 'Paul', lastName: 'Martin', birthDate: null, deathDate: null, notes: null, nickname: null },
],
unions: [], filiations: [], medias: [], annotations: [],
})
expect(getPersonById('p-1').nickname).toBe('Loulou')
expect(getPersonById('p-2').nickname).toBeNull()
})
})
+19
View File
@@ -11,6 +11,7 @@ const SCORE_WEIGHTS = {
// Poids 80 : Très pertinent
NAME_STARTS_WITH: 80,
NICKNAME_STARTS_WITH: 80,
FIRSTNAME_STARTS_WITH: 80,
LASTNAME_STARTS_WITH: 80,
PROFESSION_EXACT: 80,
@@ -22,6 +23,7 @@ const SCORE_WEIGHTS = {
BIRTH_YEAR_MATCH: 50,
DEATH_YEAR_MATCH: 50,
REGION_MATCH: 50,
NICKNAME_CONTAINS: 50,
// Poids 20 : Peu pertinent
NOTE_CONTAINS: 20,
@@ -52,6 +54,7 @@ function calculateScore(query, person) {
const birthPlace = (person.birthPlace || '').toLowerCase()
const region = (person.region || '').toLowerCase()
const note = (person.note || '').toLowerCase()
const nickname = (person.nickname || '').toLowerCase()
const birthYear = person.birthYear?.toString() || ''
const deathYear = person.deathYear?.toString() || ''
@@ -102,6 +105,12 @@ function calculateScore(query, person) {
}
}
// === SURNOM ===
if (nickname && nickname.includes(q)) {
score += nickname.startsWith(q) ? SCORE_WEIGHTS.NICKNAME_STARTS_WITH : SCORE_WEIGHTS.NICKNAME_CONTAINS
}
// === LIEUX ===
if (birthPlace && birthPlace === q) {
@@ -154,6 +163,10 @@ function getMatchReason(query, person) {
return 'Nom'
}
if ((person.nickname || '').toLowerCase().includes(q)) {
return 'Surnom'
}
if (profession && profession.includes(q)) {
return 'Profession'
}
@@ -196,6 +209,10 @@ function getMatchDetail(query, person) {
return fullName
}
if ((person.nickname || '').toLowerCase().includes(q)) {
return person.nickname
}
if (profession.toLowerCase().includes(q)) {
return profession
}
@@ -300,3 +317,5 @@ export function usePersonSearch(persons = []) {
deactivate,
}
}
export { calculateScore, getMatchReason, MIN_SCORE_THRESHOLD }
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { calculateScore, getMatchReason, MIN_SCORE_THRESHOLD } from './usePersonSearch.js'
const robert = { firstName: 'Robert', lastName: 'Dupont', nickname: 'Loulou' }
const andre = { firstName: 'André', lastName: 'Dupont', nickname: null }
describe('recherche par surnom', () => {
it('trouve une personne par son surnom, même sans nom', () => {
expect(calculateScore('loulou', robert)).toBeGreaterThanOrEqual(MIN_SCORE_THRESHOLD)
expect(getMatchReason('lou', robert)).toBe('Surnom')
})
it("ne trouve pas une personne sans surnom par un mot qui n'est pas dans son nom", () => {
expect(calculateScore('loulou', andre)).toBeLessThan(MIN_SCORE_THRESHOLD)
})
it('garde le classement par nom quand le surnom ne matche pas', () => {
expect(getMatchReason('dupont', robert)).toBe('Nom')
})
})
+2
View File
@@ -39,6 +39,8 @@ export default {
'Lieu': 'Place',
'Lieu de naissance': 'Birthplace',
'Nom de naissance': 'Birth name',
'Surnom': 'Nickname',
'Loulou': 'Lulu',
'Statut': 'Status',
'En vie': 'Living',
'Décédé(e)': 'Deceased',
+1
View File
@@ -49,6 +49,7 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) {
firstName: p.firstName || '',
lastName: p.lastName || '',
birthName: p.birthName || null,
nickname: p.nickname || null,
birthYear: dateToYear(p.birthDate),
deathYear: dateToYear(p.deathDate),
isAlive: isAlive(p.deathDate),
+8
View File
@@ -0,0 +1,8 @@
// Surnom affiché sous le nom dans l'arbre : coupé avec … au-delà de maxW (px du canevas).
// Le texte complet reste visible dans la fiche de la personne.
export function fitNickname(ctx, text, maxW) {
if (ctx.measureText(text).width <= maxW) return text
let out = text
while (out.length > 1 && ctx.measureText(`${out}…`).width > maxW) out = out.slice(0, -1)
return `${out.trimEnd()}…`
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { fitNickname } from './nickname.js'
// Mesure fictive : 7 px par caractère, suffisant pour tester la règle de coupe
const ctx = { measureText: (text) => ({ width: text.length * 7 }) }
describe('fitNickname', () => {
it('garde le surnom entier quand il tient', () => {
expect(fitNickname(ctx, 'Loulou', 150)).toBe('Loulou')
})
it('coupe avec … sans dépasser la largeur', () => {
const out = fitNickname(ctx, 'Tonton Jojo de Brive', 70)
expect(out.endsWith('…')).toBe(true)
expect(ctx.measureText(out).width).toBeLessThanOrEqual(70)
})
})