diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index ce681d5..dd8d9c4 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -18,6 +18,7 @@ import { t, dateLocale } from '../i18n/index.js' import { formatPlace } from '../utils/place.js' import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js' import { isYearOnly } from '../utils/dateInput.js' +import { ageInYears } from '../utils/age.js' import DateField from './DateField.jsx' import { runOnce } from '../utils/saveOnLeave.js' import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js' @@ -448,9 +449,9 @@ const PersonCard = ({ const hasDeathYear = Number.isFinite(deathYearNumber) const age = hasBirthYear ? person.isAlive - ? new Date().getFullYear() - birthYearNumber + ? ageInYears(person.birthDate ?? birthYearNumber) : hasDeathYear - ? deathYearNumber - birthYearNumber + ? ageInYears(person.birthDate ?? birthYearNumber, person.deathDate ?? deathYearNumber) : null : null diff --git a/frontend/src/utils/age.js b/frontend/src/utils/age.js new file mode 100644 index 0000000..53fc2c1 --- /dev/null +++ b/frontend/src/utils/age.js @@ -0,0 +1,26 @@ +import { isYearOnly } from './dateInput.js' + +// Âge en années révolues (#198) : l'anniversaire compte une fois passé, pas dès le 1er janvier. +// Une année seule (rangée au 1er janvier) n'a pas de jour : on garde la différence des années. +const partsOf = (value) => { + if (value === null || value === undefined || value === '') return null + if (Number.isFinite(value)) return { year: Number(value), md: null } + const text = String(value).slice(0, 10) + const m = text.match(/^(\d{4})(?:-(\d{2})-(\d{2}))?/) + if (!m) return null + return { year: Number(m[1]), md: m[2] && !isYearOnly(text) ? `${m[2]}-${m[3]}` : null } +} + +const today = () => { + const now = new Date() + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` +} + +// end absent = aujourd'hui ; null si la naissance (ou la fin donnée) manque +export function ageInYears(birth, end = today()) { + const from = partsOf(birth) + const to = partsOf(end) + if (!from || !to) return null + const age = to.year - from.year + return from.md && to.md && to.md < from.md ? age - 1 : age +} diff --git a/frontend/src/utils/age.test.js b/frontend/src/utils/age.test.js new file mode 100644 index 0000000..2cc2911 --- /dev/null +++ b/frontend/src/utils/age.test.js @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { ageInYears } from './age.js' + +describe('ageInYears', () => { + it("ne compte l'anniversaire qu'une fois passé (#198)", () => { + expect(ageInYears('1980-12-15', '2026-10-29')).toBe(45) + expect(ageInYears('1980-12-15', '2026-12-15')).toBe(46) + expect(ageInYears('1980-03-02T00:00:00.000Z', '2026-10-29')).toBe(46) + }) + + it('garde la différence des années pour une année seule', () => { + expect(ageInYears('1980-01-01', '2026-10-29')).toBe(46) + expect(ageInYears(1980, '2026-10-29')).toBe(46) + expect(ageInYears('1920-05-10', 1995)).toBe(75) + }) + + it('calcule un âge au décès au jour près', () => { + expect(ageInYears('1920-05-10', '1995-05-09T00:00:00.000Z')).toBe(74) + }) + + it('rend null sans naissance', () => { + expect(ageInYears(null)).toBe(null) + expect(ageInYears('', '2026-10-29')).toBe(null) + }) +}) diff --git a/frontend/src/utils/lifeTimeline.js b/frontend/src/utils/lifeTimeline.js index b3a3a36..c378a1c 100644 --- a/frontend/src/utils/lifeTimeline.js +++ b/frontend/src/utils/lifeTimeline.js @@ -8,6 +8,7 @@ import { t, dateLocale } from '../i18n/index.js' import { firstNameLabel } from './personName.js' import { formatPlace } from './place.js' +import { ageInYears } from './age.js' const EVENT_LABELS = { baptism: t('Baptême'), @@ -90,7 +91,11 @@ export function describeDate(date, end, precision) { } } -const ageAt = (birthYear, year) => (birthYear && year && year > birthYear ? t('{age} ans', { age: year - birthYear }) : '') +// Âge à une date, au jour près quand les deux dates sont complètes (#198), sinon à l'année +const ageAt = (birth, end) => { + const age = ageInYears(birth, end || null) + return age > 0 ? t('{age} ans', { age }) : '' +} const joinMeta = (...parts) => parts.filter(Boolean).join(' · ') // Le prénom d'un proche, cliquable dans la frise : il ouvre sa fiche @@ -123,6 +128,7 @@ export function buildLifeTimeline(person, family = {}, life = null) { const lifeUnions = new Map((life?.unions || []).map((u) => [String(u.id), u])) const events = life?.events || [] const birthYear = yearOf(person.birthDate) ?? person.birthYear ?? null + const birthDate = person.birthDate || birthYear const deathYear = yearOf(person.deathDate) ?? person.deathYear ?? null const lastYear = deathYear ?? new Date().getFullYear() const within = (year) => year && (!birthYear || year >= birthYear) && year <= lastYear @@ -165,14 +171,14 @@ export function buildLifeTimeline(person, family = {}, life = null) { startDatePrecision: detail.startDatePrecision || null, startPlace: detail.startPlace || '', }, meta: year - ? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year)) + ? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthDate, detail.startDate || year)) : joinMeta(place, t('date inconnue')), note: extra?.notes || '', sources: extra?.sources || '', ...milestoneFields(extra, { type: 'marriage', unionId: union.id }), }) const hasEndEvent = events.some((e) => String(e.unionId) === String(union.id) && ['divorce', 'annulment'].includes(e.type)) if (union.endYear && !hasEndEvent) { - out.push({ key: `union-end-${union.id}`, year: union.endYear, order: 3, kind: 'minor', title: t('Fin de l’union avec {name}', { name: union.partner?.firstName || partnerName }), meta: ageAt(birthYear, union.endYear) }) + out.push({ key: `union-end-${union.id}`, year: union.endYear, order: 3, kind: 'minor', title: t('Fin de l’union avec {name}', { name: union.partner?.firstName || partnerName }), meta: ageAt(birthDate, union.endYear) }) } if (union.partner && within(yearOf(union.partner.deathDate) ?? union.partner.deathYear)) { const year2 = yearOf(union.partner.deathDate) ?? union.partner.deathYear @@ -190,7 +196,7 @@ export function buildLifeTimeline(person, family = {}, life = null) { } out.push({ key: `event-${event.id}`, year, order: 1, kind: 'minor', title, - meta: joinMeta(formatPlace(event.place || ''), year ? describeDate(event.date, event.dateEnd, event.datePrecision) : t('date inconnue'), ageAt(birthYear, year)), + meta: joinMeta(formatPlace(event.place || ''), year ? describeDate(event.date, event.dateEnd, event.datePrecision) : t('date inconnue'), ageAt(birthDate, event.date || year)), note: event.notes || '', sources: event.sources || '', mediaIds: event.mediaIds || [], // Seuls les moments propres à la personne se modifient depuis l'onglet (pas ceux d'une union) @@ -219,7 +225,7 @@ export function buildLifeTimeline(person, family = {}, life = null) { out.push({ key: 'death', year: deathYear, order: 9, kind: 'mile', title: place ? t('Décès à {place}', { place: place.split(',')[0] }) : t('Décès'), - meta: deathYear ? joinMeta(describeDate(person.deathDate, details.deathDateEnd, details.deathDatePrecision), ageAt(birthYear, deathYear)) : t('date inconnue'), + meta: deathYear ? joinMeta(describeDate(person.deathDate, details.deathDateEnd, details.deathDatePrecision), ageAt(birthDate, person.deathDate || deathYear)) : t('date inconnue'), note: extra?.notes || '', sources: extra?.sources || '', ...milestoneFields(extra, { type: 'death' }), })