mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 07:50:58 +00:00
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:
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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' }),
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user