Merge pull request #80 from IliesAllali/deces-sans-date

Dates : une année seule, et décédé(e) sans date (#77)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-17 13:30:02 +02:00
commit 9ccd5a2e30
21 files changed
+243 -23

No files matched your search

@@ -0,0 +1,2 @@
-- Personne décédée sans date connue (issue #77)
ALTER TABLE "persons" ADD COLUMN "isDeceased" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -214,6 +214,8 @@ model Person {
birthName String?
birthDate DateTime?
deathDate DateTime?
// Décédé(e) sans date connue (issue #77) : vrai seulement quand deathDate est vide
isDeceased Boolean @default(false)
birthPlace String?
// Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … »
birthDatePrecision String?
+5
View File
@@ -73,6 +73,7 @@ const personCreateSchema = z.object({
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
@@ -88,6 +89,7 @@ const personUpdateSchema = z
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
@@ -378,6 +380,7 @@ async function applyContributionChange(
birthName: toTrimmedStringOrNull(parsed.data.birthName),
birthDate: toDateOrNull(parsed.data.birthDate),
deathDate: toDateOrNull(parsed.data.deathDate),
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace),
profession: toTrimmedStringOrNull(parsed.data.profession),
region: toTrimmedStringOrNull(parsed.data.region),
@@ -434,6 +437,7 @@ async function applyContributionChange(
birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined,
birthDate: nextDates.birthDate,
deathDate: nextDates.deathDate,
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined,
@@ -830,6 +834,7 @@ async function snapshotEntity(
birthName: true,
birthDate: true,
deathDate: true,
isDeceased: true,
birthPlace: true,
profession: true,
region: true,
+5
View File
@@ -477,6 +477,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
if (deathDate) lines.push(`2 DATE ${deathDate}`)
if (person.deathPlace) lines.push(`2 PLAC ${oneLine(person.deathPlace)}`)
pushNotesAndSources(lines, 2, deathDetails)
} else if (person.isDeceased) {
// Décédé(e) sans rien d'autre : la forme courte de la norme
lines.push('1 DEAT Y')
}
// Le champ « profession » s'écrit seulement s'il n'est pas déjà un des métiers datés
@@ -692,6 +695,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
sex,
birthDate,
deathDate,
// « 1 DEAT Y » ou un décès sans date : décédé(e), date inconnue
isDeceased: Boolean(deatNode) && !deathDate,
birthPlace,
profession,
notes,
+13
View File
@@ -89,6 +89,7 @@ const createPersonSchema = z.object({
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
@@ -104,6 +105,7 @@ const patchPersonSchema = z
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
@@ -1310,6 +1312,8 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
birthName: toTrimmedStringOrNull(payload.data.birthName),
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
isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased),
birthPlace: toTrimmedStringOrNull(payload.data.birthPlace),
profession: toTrimmedStringOrNull(payload.data.profession),
region: toTrimmedStringOrNull(payload.data.region),
@@ -1366,6 +1370,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
birthName?: string | null
birthDate?: Date | null
deathDate?: Date | null
isDeceased?: boolean
birthPlace?: string | null
profession?: string | null
region?: string | null
@@ -1395,6 +1400,14 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.deathDate = toDateOrNull(payload.data.deathDate)
}
if (payload.data.isDeceased !== undefined) {
data.isDeceased = payload.data.isDeceased
}
// Avec une date de décès, le drapeau « sans date » n'a plus lieu d'être
if (data.deathDate) {
data.isDeceased = false
}
if (payload.data.birthPlace !== undefined) {
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
}
+2
View File
@@ -11,6 +11,7 @@ export type GraphPayload = {
birthName: string | null
birthDate: string | null
deathDate: string | null
isDeceased: boolean
birthPlace: string | null
profession: string | null
region: string | null
@@ -85,6 +86,7 @@ export function buildGraphPayload(
birthName: person.birthName,
birthDate: formatDate(person.birthDate),
deathDate: formatDate(person.deathDate),
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
birthPlace: person.birthPlace,
profession: person.profession,
region: person.region,
+21 -1
View File
@@ -487,7 +487,7 @@ describe('Graph routes', () => {
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Ragot', rootPersonId: null })
prismaMock.person.findMany.mockResolvedValue([
{ ...base, id: 'p-1', firstName: 'Louis', lastName: 'Ragot', sex: 'male', birthDate: new Date('1879-01-01T00:00:00Z'), birthDatePrecision: 'about', birthPlace: 'La Roche-sur-Yon', deathDate: new Date('1950-05-02T00:00:00Z'), deathPlace: 'Nantes', nickname: 'Loulou', profession: 'Instituteur' },
{ ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null },
{ ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null, isDeceased: true },
{ ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'male', birthDate: null, birthDatePrecision: null, birthPlace: null },
])
prismaMock.union.findMany.mockResolvedValue([
@@ -513,6 +513,8 @@ describe('Graph routes', () => {
expect(text).toContain('1 OCCU Instituteur\n2 DATE FROM 1900 TO 1935')
expect(text.match(/1 OCCU/g)).toHaveLength(1)
expect(text).toContain('2 PEDI adopted')
// Décédée sans date (issue #77) : la forme courte de la norme
expect(text).toContain('1 NAME Marie /Bodin/\n1 SEX F\n1 DEAT Y\n')
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
@@ -631,6 +633,24 @@ describe('Graph routes', () => {
})
})
describe('Décès sans date (issue #77)', () => {
it('marque décédé sans date, et une date de décès retire le drapeau', 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({ isDeceased: true })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: true })
expect((await patch({ deathDate: '1950-01-01', isDeceased: true })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: false, deathDate: new Date('1950-01-01T00:00:00.000Z') })
expect((await patch({ isDeceased: false, deathDate: null })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: false, deathDate: null })
await app.close()
})
})
describe('Moments de l’onglet Vie', () => {
it('un admin ajoute un moment avec deux souvenirs, le modifie sans changer son type, le supprime', async () => {
const app = createApp()
+22 -5
View File
@@ -241,6 +241,7 @@ function buildContributionChangesFromDraft(draft = {}) {
birthName: person.birthName ? String(person.birthName).trim() : null,
birthDate: person.birthDate || null,
deathDate: person.deathDate || null,
isDeceased: !person.deathDate && Boolean(person.isDeceased),
},
})
@@ -289,7 +290,10 @@ function buildContributionChangesFromDraft(draft = {}) {
if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'note')) after.notes = source.note || null
if (Object.prototype.hasOwnProperty.call(source, 'isAlive') && source.isAlive === true) after.deathDate = null
if (Object.prototype.hasOwnProperty.call(source, 'isAlive')) {
if (source.isAlive === true) after.deathDate = null
after.isDeceased = source.isAlive === false
}
if (Object.keys(after).length > 0) {
changes.push({
@@ -1073,6 +1077,7 @@ function App() {
birthName: formData.birthName || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })),
siblingParentLinks,
},
@@ -1098,6 +1103,7 @@ function App() {
birthName: formData.birthName,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
},
auth.userAuth.token
)
@@ -1230,7 +1236,10 @@ function App() {
if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive') && rawChanges.isAlive === true) payload.deathDate = null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive')) {
if (rawChanges.isAlive === true) payload.deathDate = null
payload.isDeceased = rawChanges.isAlive === false
}
if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null
if (Object.keys(payload).length > 0) {
@@ -1510,8 +1519,13 @@ function App() {
if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null
if (Object.prototype.hasOwnProperty.call(changes, 'note')) localPerson.note = changes.note || null
if (Object.prototype.hasOwnProperty.call(changes, 'isAlive') && changes.isAlive === true) {
localPerson.deathYear = null
if (Object.prototype.hasOwnProperty.call(changes, 'isAlive')) {
if (changes.isAlive === true) {
localPerson.deathYear = null
localPerson.deathDate = null
}
localPerson.isAlive = changes.isAlive === true
localPerson.isDeceased = changes.isAlive === false && !localPerson.deathYear
}
tree.setSelectedPerson({ ...localPerson })
}
@@ -1532,7 +1546,10 @@ function App() {
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'isAlive') && changes.isAlive === true) payload.deathDate = null
if (Object.prototype.hasOwnProperty.call(changes, 'isAlive')) {
if (changes.isAlive === true) payload.deathDate = null
payload.isDeceased = changes.isAlive === false
}
if (Object.prototype.hasOwnProperty.call(changes, 'note')) payload.notes = changes.note || null
if (Object.keys(payload).length > 0) {
@@ -104,3 +104,10 @@
.add-person-panel, .add-person-panel--open { transform: none; transition: opacity 180ms ease, visibility 0s linear 180ms; }
.add-person-panel--open { transition: opacity 180ms ease, visibility 0s; }
}
/* 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); }
/* 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; }
+18 -4
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import DateField from './DateField.jsx'
import { UserPlus, X, Search, ChevronDown } from 'lucide-react'
import './AddPersonPanel.css'
import PzBusy from './PzBusy.jsx'
@@ -35,6 +36,10 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
const [birthName, setBirthName] = useState('')
const [birthDate, setBirthDate] = useState('')
const [deathDate, setDeathDate] = useState('')
// Décédé(e) sans date connue (issue #77)
const [isDeceased, setIsDeceased] = useState(false)
// Les champs de date gardent leur texte : on les recrée après chaque ajout
const [dateFieldsKey, setDateFieldsKey] = useState(0)
const [relations, setRelations] = useState([])
const [relationSearch, setRelationSearch] = useState('')
const [relationDropdownOpen, setRelationDropdownOpen] = useState(false)
@@ -88,6 +93,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
setBirthName('')
setBirthDate('')
setDeathDate('')
setIsDeceased(false)
setDateFieldsKey((k) => k + 1)
setRelations([])
setTouched({})
setJustAdded(false)
@@ -134,6 +141,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
birthName: birthName.trim() || null,
birthDate: birthDate || null,
deathDate: deathDate || null,
isDeceased: !deathDate && isDeceased,
relations,
})
}
@@ -173,7 +181,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<form className="apn-form" onSubmit={handleSubmit}>
<div className="apn-scroll">
<div className="pz-row2">
<div className="pz-row2 apn-row">
<div className="pz-field">
<label htmlFor="ap-firstName">{t('Prénom')}</label>
<input
@@ -217,14 +225,20 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
/>
</div>
<div className="pz-row2">
<div className="pz-row2 apn-row">
<div className="pz-field">
<label htmlFor="ap-birthDate">{t('Naissance')}</label>
<input id="ap-birthDate" type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} />
<DateField key={`b-${dateFieldsKey}`} id="ap-birthDate" value={birthDate} onChange={setBirthDate} />
</div>
<div className="pz-field">
<label htmlFor="ap-deathDate">{t('Décès')}</label>
<input id="ap-deathDate" type="date" value={deathDate} onChange={(e) => setDeathDate(e.target.value)} />
<DateField key={`d-${dateFieldsKey}`} id="ap-deathDate" value={deathDate} onChange={setDeathDate} />
{!deathDate && (
<label className="apn-deceased">
<input type="checkbox" checked={isDeceased} onChange={(e) => setIsDeceased(e.target.checked)} />
{t('Décédé(e), date inconnue')}
</label>
)}
</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
import { useState } from 'react'
import { t } from '../i18n/index.js'
import { dateToInputText, parseDateInput } from '../utils/dateInput.js'
// Champ de date de la fiche : une année seule ou une date complète (issue #77). Le texte tapé reste tel
// quel ; la valeur ne remonte qu'une fois comprise, sinon le champ le signale sans rien enregistrer.
export default function DateField({ id, value, onChange, placeholder, className = '' }) {
const [text, setText] = useState(() => dateToInputText(value))
const invalid = parseDateInput(text) === undefined
return (
<span className="pz-date">
<input
id={id}
type="text"
inputMode="numeric"
className={className}
value={text}
placeholder={placeholder || t('Année ou date')}
aria-invalid={invalid || undefined}
onChange={(e) => {
setText(e.target.value)
const parsed = parseDateInput(e.target.value)
if (parsed !== undefined) onChange(parsed)
}}
/>
{invalid && <span className="pz-date-hint" role="status">{t('Une année (1950) ou une date (12/03/1950)')}</span>}
</span>
)
}
+4
View File
@@ -175,6 +175,10 @@
.pc-label { flex: none; font: 400 13.5px var(--pz-sans); color: var(--pz-muted); }
.pc-row .value { font: 400 14px/1.4 var(--pz-sans); color: var(--pz-ink); text-align: right; font-variant-numeric: tabular-nums; }
.pc-row .pc-input { max-width: 62%; text-align: right; }
/* Date : une année seule ou une date complète (issue #77), avec une aide si le texte n'en est pas une */
.pc-row .pz-date { justify-items: end; max-width: 62%; }
.pc-row .pz-date .pc-input { max-width: 100%; }
.pc-row .pz-date-hint { text-align: right; }
.pc-note { margin: 0; font: 400 italic 16px/1.5 var(--pz-serif); color: var(--pz-ink-2); white-space: pre-wrap; }
.pc-alive {
+25 -9
View File
@@ -12,6 +12,8 @@ import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import { t, dateLocale } from '../i18n/index.js'
import { formatPlace } from '../utils/place.js'
import { isYearOnly } from '../utils/dateInput.js'
import DateField from './DateField.jsx'
const TYPE_LABELS = {
photo: t('Photo'),
@@ -329,7 +331,9 @@ const PersonCard = ({
if (union) {
const p1 = persons.find(p => p.id === union.partner1Id)
const p2 = persons.find(p => p.id === union.partner2Id)
return `${p1?.firstName || '?'} + ${p2?.firstName || '?'} (${unionTypeLabel(union.unionType)})`
// Union sans type (fréquent après un import GEDCOM) : pas de « (null) » à côté des prénoms
const kind = union.unionType ? ` (${unionTypeLabel(union.unionType)})` : ''
return `${p1?.firstName || '?'} + ${p2?.firstName || '?'}${kind}`
}
}
@@ -363,14 +367,26 @@ const PersonCard = ({
}
// Rendu d'un champ éditable ou lecture seule
const renderEditableValue = ({ field, value, placeholder, type = 'text', format = null }) => {
const renderEditableValue = ({ field, value, placeholder, type = 'text', format = null, emptyLabel = '-' }) => {
if (!editMode) {
const isEmptyValue = value === null || value === undefined || value === ''
// Une date complète se lit « 22 octobre 1928 », pas « 1928-10-22 »
const shown = type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(String(value))
? new Date(`${String(value).slice(0, 10)}T00:00:00Z`).toLocaleDateString(dateLocale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
: format ? format(value) : value
return <span className="value">{isEmptyValue ? '-' : shown}</span>
// Une date complète se lit « 22 octobre 1928 », une année seule « 1928 », pas « 1 janvier 1928 »
let shown = format ? format(value) : value
if (type === 'date' && isYearOnly(value)) shown = String(value).slice(0, 4)
else if (type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(String(value))) {
shown = new Date(`${String(value).slice(0, 10)}T00:00:00Z`).toLocaleDateString(dateLocale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
}
return <span className="value">{isEmptyValue ? emptyLabel : shown}</span>
}
if (type === 'date') {
return (
<DateField
key={`${person.id}-${field}`}
className="pc-input value--editable"
value={getEditValue(field, value)}
onChange={(next) => handleFieldChange(field, next)}
/>
)
}
return (
<input
@@ -395,7 +411,7 @@ const PersonCard = ({
const died = person.deathYear || (person.deathDate ? String(person.deathDate).slice(0, 4) : '')
const parts = []
if (born) parts.push(`${born}${person.birthPlace ? `, ${formatPlace(person.birthPlace)}` : ''}`)
if (!person.isAlive && died) parts.push(died)
if (!person.isAlive) parts.push(died || '?')
return parts.join(' – ')
})()
const ageLabel = age === null ? null : person.isAlive ? t('{age} ans', { age }) : t('{age} ans au décès', { age })
@@ -697,7 +713,7 @@ const PersonCard = ({
{(!getEditValue('isAlive', person.isAlive) || (!editMode && !person.isAlive)) && (
<div className="pc-row">
<span className="pc-label">{t('Décès')}</span>
{renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date' })}
{renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') })}
</div>
)}
{!editMode && (
+3 -1
View File
@@ -615,7 +615,9 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
ctx.font = '400 11px "DM Sans", system-ui, sans-serif'
ctx.fillStyle = COLORS.textLight
const birthYearLabel = person.birthYear ?? '-'
const years = person.deathYear ? `${birthYearLabel}–${person.deathYear}` : `${birthYearLabel}`
const years = person.deathYear
? `${birthYearLabel}–${person.deathYear}`
: person.isAlive === false ? `${birthYearLabel}–?` : `${birthYearLabel}`
ctx.fillText(years, 0, 15)
}
+3 -1
View File
@@ -107,7 +107,9 @@ function mapRemotePersonToLocal(person, current = null) {
birthDate: person?.birthDate || current?.birthDate || null,
deathDate: person?.deathDate || current?.deathDate || null,
deathYear,
isAlive: !deathYear,
// Décédé(e) sans date connue (issue #77)
isDeceased: Boolean(person?.isDeceased) && !deathYear,
isAlive: !deathYear && !person?.isDeceased,
frameType: current?.frameType || pickFrameType(person?.id),
note: person?.notes || null,
birthPlace: person?.birthPlace || null,
+6
View File
@@ -397,4 +397,10 @@ export default {
'Souvenirs de {name}': 'Memories of {name}',
'Une photo, une vidéo, un enregistrement ou un document, comme un acte en PDF. Ils rejoignent aussi ses souvenirs.': 'A photo, a video, a recording or a document, such as a record as a PDF. They also join their memories.',
'Merci, c’est envoyé. Le moment apparaîtra dans sa vie quand la personne qui gère l’arbre l’aura accepté.': 'Thanks, it has been sent. The moment will show in their life once the person who manages the tree has accepted it.',
// Dates : année seule, décès sans date (issue #77)
'Année ou date': 'Year or date',
'Une année (1950) ou une date (12/03/1950)': 'A year (1950) or a date (12/03/1950)',
'Date inconnue': 'Date unknown',
'date inconnue': 'date unknown',
'Décédé(e), date inconnue': 'Deceased, date unknown',
}
+4
View File
@@ -92,6 +92,10 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
transition: border-color 160ms ease, box-shadow 160ms ease; appearance: none;
}
.pz-field textarea { min-height: 96px; resize: vertical; }
/* Champ de date : une année seule ou une date complète (DateField) */
.pz-date { display: grid; align-content: start; gap: 4px; min-width: 0; }
.pz-date input[aria-invalid="true"] { border-color: var(--pz-a500); }
.pz-date-hint { font: 400 12px/1.35 var(--pz-sans); color: var(--pz-a700); }
.pz-input::placeholder, .pz-field input::placeholder, .pz-field textarea::placeholder { color: var(--pz-faint); }
.pz-input:focus, .pz-field input:focus, .pz-field textarea:focus, .pz-field select:focus {
outline: none; border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100);
+38
View File
@@ -0,0 +1,38 @@
// Saisie d'une date dans la fiche : une année seule (« 1950 ») ou une date complète (« 12/03/1950 »).
// L'année seule se range au 1er janvier, la convention de toute l'appli (issue #77).
const pad = (n) => String(n).padStart(2, '0')
// Valeur stockée (« 1950-01-01 », « 1950 », « 1950-03-12 ») vers le texte du champ
export function dateToInputText(value) {
const raw = String(value ?? '').trim()
if (!raw) return ''
const full = raw.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (full) return full[2] === '01' && full[3] === '01' ? full[1] : `${full[3]}/${full[2]}/${full[1]}`
const year = raw.match(/^(\d{4})$/)
return year ? year[1] : raw
}
// Texte du champ vers la valeur stockée : '' si vide, « AAAA-MM-JJ » si valable, undefined si on ne comprend pas
export function parseDateInput(text) {
const raw = String(text ?? '').trim()
if (!raw) return ''
if (/^\d{4}$/.test(raw)) return `${raw}-01-01`
let day
let month
let year
const dmy = raw.match(/^(\d{1,2})[/.\-\s](\d{1,2})[/.\-\s](\d{4})$/)
const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (dmy) [, day, month, year] = dmy
else if (iso) [, year, month, day] = iso
else return undefined
const d = Number(day)
const m = Number(month)
const y = Number(year)
const date = new Date(Date.UTC(y, m - 1, d))
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return undefined
return `${year}-${pad(m)}-${pad(d)}`
}
// Une année seule (rangée au 1er janvier) se lit « 1950 », pas « 1 janvier 1950 »
export const isYearOnly = (value) => /^\d{4}(-01-01)?$/.test(String(value ?? '').slice(0, 10))
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { dateToInputText, isYearOnly, parseDateInput } from './dateInput.js'
describe('saisie d’une date (issue #77)', () => {
it('une année seule ou une date complète', () => {
expect(parseDateInput('1950')).toBe('1950-01-01')
expect(parseDateInput('12/03/1950')).toBe('1950-03-12')
expect(parseDateInput('2.3.1950')).toBe('1950-03-02')
expect(parseDateInput('1950-03-12')).toBe('1950-03-12')
expect(parseDateInput(' ')).toBe('')
})
it('refuse ce qui n’est pas une date', () => {
expect(parseDateInput('31/02/1950')).toBeUndefined()
expect(parseDateInput('195')).toBeUndefined()
expect(parseDateInput('vers 1950')).toBeUndefined()
})
it('réaffiche la valeur comme on l’a tapée', () => {
expect(dateToInputText('1950-01-01')).toBe('1950')
expect(dateToInputText('1950-03-12')).toBe('12/03/1950')
expect(dateToInputText(null)).toBe('')
expect(isYearOnly('1950-01-01')).toBe(true)
expect(isYearOnly('1950-03-12')).toBe(false)
})
})
+2 -2
View File
@@ -193,13 +193,13 @@ export function buildLifeTimeline(person, family = {}, life = null) {
if (within(year)) out.push({ key: `parent-death-${parent.id}`, year, order: 6, kind: 'fam', title: parentWord(parent) })
}
if (deathYear) {
if (deathYear || person.isAlive === false) {
const place = formatPlace(details.deathPlace || '')
const extra = sourcesOf('death')
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: joinMeta(describeDate(person.deathDate, details.deathDateEnd, details.deathDatePrecision), ageAt(birthYear, deathYear)),
meta: deathYear ? joinMeta(describeDate(person.deathDate, details.deathDateEnd, details.deathDatePrecision), ageAt(birthYear, deathYear)) : t('date inconnue'),
note: extra?.notes || '', sources: extra?.sources || '',
...milestoneFields(extra, { type: 'death' }),
})
+5
View File
@@ -61,6 +61,11 @@ describe('frise de vie', () => {
expect(withMedia.find((e) => e.title === 'Maire').mediaIds).toEqual(['photo-1', 'photo-2'])
})
it('décès sans date : le repère reste, en bas, « date inconnue » (issue #77)', () => {
const entries = buildLifeTimeline({ id: 1, firstName: 'X', birthYear: 1900, isAlive: false }, {}, null)
expect(entries.map((e) => [e.title, e.year, e.meta])).toEqual([['Naissance', 1900, ''], ['Décès', null, 'date inconnue']])
})
it('dit les dates comme on les dit', () => {
expect(describeDate('1879-01-01', null, 'about')).toBe('vers 1879')
expect(describeDate('1879-01-01', null, 'before')).toBe('avant 1879')