Âge : l'anniversaire compte une fois passé (#198)

L'âge d'une personne vivante était l'année en cours moins l'année de naissance : né en décembre,
on prenait un an dès janvier. Même défaut pour l'âge au décès et les âges de l'onglet Vie.
Quand les deux dates sont complètes, l'âge se compte au jour près (utils/age.js) ; une année seule
garde la différence des années.

Fixes #198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-29 12:23:45 +01:00
1 parent 3dfc8625e9
commit d013c8b8fc
4 files changed
+65 -7

No files matched your search

+3 -2
View File
@@ -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
+26
View File
@@ -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
}
+25
View File
@@ -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)
})
})
+11 -5
View File
@@ -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' }),
})