diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 126c63f..bbe67da 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -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, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index de1805b..27dd338 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -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) } diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 78a4a04..86c83bb 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -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, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 5f9b606..e1e58ab 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -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() + }) +}) diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index f09bdb6..1a08cdf 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -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() + }) +}) diff --git a/backend/test/graph-payload.test.ts b/backend/test/graph-payload.test.ts index bb18e36..e534aec 100644 --- a/backend/test/graph-payload.test.ts +++ b/backend/test/graph-payload.test.ts @@ -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. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 07f2d45..98d536d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index 288e5fa..4f65abb 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -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 /> +
+ + setNickname(e.target.value)} + placeholder={t('Loulou')} + autoComplete="off" + maxLength={120} + /> +
+
diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index 2473232..bae4c0c 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -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'), diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index 591fd03..673363e 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -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; } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 58f0697..c6453a7 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -544,6 +544,7 @@ const PersonCard = ({ {person.firstName} {person.lastName} )} + {!editMode && person.nickname &&

{person.nickname}

} {!editMode && lifeLine &&

{lifeLine}

}
{editMode && {t('Édition')}} @@ -697,6 +698,12 @@ const PersonCard = ({ {renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') })}
)} + {(person.nickname || editMode) && ( +
+ {t('Surnom')} + {renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') })} +
+ )} {editMode && (
{t('Statut')} diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 25b52ce..9e17dc3 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -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() diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index 90e0f62..6e04583 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -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, diff --git a/frontend/src/data/mockData.test.js b/frontend/src/data/mockData.test.js index ee6caf9..a109f9e 100644 --- a/frontend/src/data/mockData.test.js +++ b/frontend/src/data/mockData.test.js @@ -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() + }) +}) diff --git a/frontend/src/hooks/usePersonSearch.js b/frontend/src/hooks/usePersonSearch.js index 909fab6..404e620 100644 --- a/frontend/src/hooks/usePersonSearch.js +++ b/frontend/src/hooks/usePersonSearch.js @@ -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 } diff --git a/frontend/src/hooks/usePersonSearch.test.js b/frontend/src/hooks/usePersonSearch.test.js new file mode 100644 index 0000000..ee5721d --- /dev/null +++ b/frontend/src/hooks/usePersonSearch.test.js @@ -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') + }) +}) diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 4ee3043..0e92719 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -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', diff --git a/frontend/src/utils/graphAdapter.js b/frontend/src/utils/graphAdapter.js index c079b16..44f43de 100644 --- a/frontend/src/utils/graphAdapter.js +++ b/frontend/src/utils/graphAdapter.js @@ -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), diff --git a/frontend/src/utils/nickname.js b/frontend/src/utils/nickname.js new file mode 100644 index 0000000..d10b7a6 --- /dev/null +++ b/frontend/src/utils/nickname.js @@ -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()}…` +} diff --git a/frontend/src/utils/nickname.test.js b/frontend/src/utils/nickname.test.js new file mode 100644 index 0000000..8043880 --- /dev/null +++ b/frontend/src/utils/nickname.test.js @@ -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) + }) +})