From 1e8f8f923a992473c5c74a82e9c66115177a419c Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sat, 17 Oct 2026 13:27:53 +0200 Subject: [PATCH] feat(dates): une annee seule, et decede(e) sans date (issue #77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Champ de date de la fiche et du formulaire « Ajouter quelqu'un » : une annee seule (1950) ou une date complete (12/03/1950), une aide si le texte n'en est pas une. L'annee seule s'affiche « 1950 » et non plus « 1 janvier 1950 ». - Statut « Decede(e) » sans date : persons.isDeceased (migration additive), « 1908–? » sous le portrait, « Date inconnue » dans la fiche, en bas de la frise. Import de « 1 DEAT Y », export en « 1 DEAT Y ». Une date de deces retire le drapeau. - Parents : plus de « (null) » quand l'union n'a pas de type. Closes #77 Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 2 + backend/prisma/schema.prisma | 2 + backend/src/routes/contributions.ts | 5 +++ backend/src/routes/gedcom.ts | 5 +++ backend/src/routes/trees.ts | 13 +++++++ backend/src/utils/graph-payload.ts | 2 + backend/test/api-routes.test.ts | 22 ++++++++++- frontend/src/App.jsx | 27 ++++++++++--- frontend/src/components/AddPersonPanel.css | 7 ++++ frontend/src/components/AddPersonPanel.jsx | 22 +++++++++-- frontend/src/components/DateField.jsx | 30 +++++++++++++++ frontend/src/components/PersonCard.css | 4 ++ frontend/src/components/PersonCard.jsx | 34 ++++++++++++----- frontend/src/components/galaxy/renderers.js | 4 +- frontend/src/data/mockData.js | 4 +- frontend/src/i18n/en/fiche.js | 6 +++ frontend/src/styles/pz.css | 4 ++ frontend/src/utils/dateInput.js | 38 +++++++++++++++++++ frontend/src/utils/dateInput.test.js | 26 +++++++++++++ frontend/src/utils/lifeTimeline.js | 4 +- frontend/src/utils/lifeTimeline.test.js | 5 +++ 21 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 backend/prisma/migrations/20261017_004_deces_sans_date/migration.sql create mode 100644 frontend/src/components/DateField.jsx create mode 100644 frontend/src/utils/dateInput.js create mode 100644 frontend/src/utils/dateInput.test.js diff --git a/backend/prisma/migrations/20261017_004_deces_sans_date/migration.sql b/backend/prisma/migrations/20261017_004_deces_sans_date/migration.sql new file mode 100644 index 0000000..14f6e2c --- /dev/null +++ b/backend/prisma/migrations/20261017_004_deces_sans_date/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4ce8cf7..098b07e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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? diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 4facfe3..126c63f 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -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, diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 4a33083..ed675a9 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -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, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index dce079b..de1805b 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -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) } diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 4ae8370..78a4a04 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -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, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index d603079..5f9b606 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -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() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 44334dd..07f2d45 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) { diff --git a/frontend/src/components/AddPersonPanel.css b/frontend/src/components/AddPersonPanel.css index 9db624c..007f39c 100644 --- a/frontend/src/components/AddPersonPanel.css +++ b/frontend/src/components/AddPersonPanel.css @@ -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; } diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index f0f3ef1..b23dc40 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -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
-
+
-
+
- setBirthDate(e.target.value)} /> +
- setDeathDate(e.target.value)} /> + + {!deathDate && ( + + )}
diff --git a/frontend/src/components/DateField.jsx b/frontend/src/components/DateField.jsx new file mode 100644 index 0000000..a41ef4f --- /dev/null +++ b/frontend/src/components/DateField.jsx @@ -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 ( + + { + setText(e.target.value) + const parsed = parseDateInput(e.target.value) + if (parsed !== undefined) onChange(parsed) + }} + /> + {invalid && {t('Une année (1950) ou une date (12/03/1950)')}} + + ) +} diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index 0946a70..591fd03 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -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 { diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 0e0c00a..58f0697 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -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 {isEmptyValue ? '-' : shown} + // 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 {isEmptyValue ? emptyLabel : shown} + } + if (type === 'date') { + return ( + handleFieldChange(field, next)} + /> + ) } return ( {t('Décès')} - {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') })}
)} {!editMode && ( diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 68f41d2..25b52ce 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -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) } diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index 3ccf6f9..90e0f62 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -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, diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 732dc12..4ee3043 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -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', } diff --git a/frontend/src/styles/pz.css b/frontend/src/styles/pz.css index e73758e..d62a4b2 100644 --- a/frontend/src/styles/pz.css +++ b/frontend/src/styles/pz.css @@ -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); diff --git a/frontend/src/utils/dateInput.js b/frontend/src/utils/dateInput.js new file mode 100644 index 0000000..98b68d1 --- /dev/null +++ b/frontend/src/utils/dateInput.js @@ -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)) diff --git a/frontend/src/utils/dateInput.test.js b/frontend/src/utils/dateInput.test.js new file mode 100644 index 0000000..e0a0b8f --- /dev/null +++ b/frontend/src/utils/dateInput.test.js @@ -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) + }) +}) diff --git a/frontend/src/utils/lifeTimeline.js b/frontend/src/utils/lifeTimeline.js index 862ff83..8457b20 100644 --- a/frontend/src/utils/lifeTimeline.js +++ b/frontend/src/utils/lifeTimeline.js @@ -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' }), }) diff --git a/frontend/src/utils/lifeTimeline.test.js b/frontend/src/utils/lifeTimeline.test.js index d36f156..a4b0105 100644 --- a/frontend/src/utils/lifeTimeline.test.js +++ b/frontend/src/utils/lifeTimeline.test.js @@ -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')