mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 23:50:58 +00:00
feat(fiche): onglet Vie, la frise de vie d'une personne
Naissance, unions et deces en grands reperes (direction A2 choisie par Patch le 16/10), evenements importes plus petits, famille proche tiree de l'arbre en retrait, sources repliees. Evenements charges a l'ouverture de l'onglet via GET /persons/:id/life, donnees propres a la demo pour Robert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8372d3f29b
commit
557956fedd
8 files changed
+518
-12
No files matched your search
@@ -59,6 +59,14 @@ export async function fetchTreeGraph(treeId, token) {
|
||||
}
|
||||
}
|
||||
|
||||
// Frise de vie d'une personne : événements, précisions de date, lieux des unions
|
||||
export async function fetchPersonLife(treeId, personId, token) {
|
||||
return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listTrees(token) {
|
||||
const data = await apiRequest('/trees', {
|
||||
method: 'GET',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* Onglet Vie de la fiche (direction A2 retenue le 16/10). Variables --pz-* : src/styles/pz.css */
|
||||
|
||||
.lt { padding: 2px 0 4px; }
|
||||
.lt-rail { position: relative; list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
||||
.lt-rail::before { content: ""; position: absolute; left: 54px; top: 12px; bottom: 12px; width: 1px; background: var(--pz-line); }
|
||||
|
||||
.lt-ev { position: relative; display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 0 30px; padding: 8px 0; }
|
||||
.lt-ev::after {
|
||||
content: ""; position: absolute; left: 51px; top: 14px; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--pz-surface); box-shadow: inset 0 0 0 1.5px var(--pz-a500);
|
||||
}
|
||||
.lt-year { font: 400 13.5px/1.35 var(--pz-sans); color: var(--pz-ink-2); font-variant-numeric: tabular-nums; text-align: right; padding-top: 1px; }
|
||||
.lt-title { font: 400 14px/1.35 var(--pz-sans); color: var(--pz-ink); overflow-wrap: anywhere; }
|
||||
.lt-meta { font: 400 13px/1.45 var(--pz-sans); color: var(--pz-muted); margin-top: 2px; }
|
||||
.lt-note { font: 400 13px/1.5 var(--pz-sans); color: var(--pz-ink-2); margin: 4px 0 0; white-space: pre-line; }
|
||||
|
||||
/* Grands repères : naissance, unions, décès */
|
||||
.lt-ev--mile { padding: 12px 0 10px; }
|
||||
.lt-ev--mile::after { top: 19px; left: 50px; width: 9px; height: 9px; background: var(--pz-a500); }
|
||||
.lt-ev--mile .lt-title { font: 400 21px/1.2 var(--pz-serif); letter-spacing: -.005em; }
|
||||
.lt-ev--mile .lt-year { font-size: 14px; color: var(--pz-ink); padding-top: 6px; }
|
||||
|
||||
/* Famille proche, en retrait */
|
||||
.lt-ev--fam::after { box-shadow: inset 0 0 0 1.5px var(--pz-line); }
|
||||
.lt-ev--fam .lt-title { color: var(--pz-muted); }
|
||||
|
||||
.lt-sources { margin-top: 3px; }
|
||||
.lt-src {
|
||||
appearance: none; border: 0; background: none; padding: 2px 0; cursor: pointer;
|
||||
font: 500 12.5px var(--pz-sans); color: var(--pz-a700); text-underline-offset: 3px;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) { .lt-src:hover { text-decoration: underline; } }
|
||||
.lt-src:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; border-radius: 4px; }
|
||||
.lt-src-list {
|
||||
list-style: none; margin: 6px 0 0; padding: 8px 10px; display: grid; gap: 4px;
|
||||
background: var(--pz-bg); border-radius: var(--pz-r-s);
|
||||
font: 400 12.5px/1.5 var(--pz-sans); color: var(--pz-ink-2);
|
||||
animation: lt-open 180ms var(--pz-ease) both;
|
||||
}
|
||||
@keyframes lt-open { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.lt-failed { margin-top: 10px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .lt-src-list { animation: none; } }
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { buildLifeTimeline } from '../utils/lifeTimeline.js'
|
||||
import './LifeTimeline.css'
|
||||
|
||||
// Onglet Vie de la fiche : naissance, unions et décès en grands repères, les autres événements
|
||||
// plus petits entre eux, la famille proche en retrait. Les sources restent repliées.
|
||||
function Sources({ text }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const list = text.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
if (list.length === 0) return null
|
||||
return (
|
||||
<div className="lt-sources">
|
||||
<button type="button" className="lt-src" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||
{list.length === 1 ? t('1 source') : t('{n} sources', { n: list.length })}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="lt-src-list">
|
||||
{list.map((source) => <li key={source}>{source}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LifeTimeline({ person, family, life, loading = false, failed = false }) {
|
||||
const entries = buildLifeTimeline(person, family, life)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="pc-empty">
|
||||
<p className="pz-sub">{t('Pas encore de date connue pour {name}.', { name: person.firstName })}</p>
|
||||
<p className="pz-small">{t('Les dates de naissance, d’union et de décès, et celles de sa famille, apparaîtront ici.')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lt" aria-busy={loading || undefined}>
|
||||
<ol className="lt-rail">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.key} className={`lt-ev lt-ev--${entry.kind}`}>
|
||||
<span className="lt-year">{entry.year ?? ''}</span>
|
||||
<div className="lt-body">
|
||||
<div className="lt-title">{entry.title}</div>
|
||||
{entry.meta && <div className="lt-meta">{entry.meta}</div>}
|
||||
{entry.note && <p className="lt-note">{entry.note}</p>}
|
||||
{entry.sources && <Sources text={entry.sources} />}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{failed && <p className="pz-small lt-failed">{t('Les autres événements n’ont pas pu être chargés.')}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, filiations, persons, unions } from '../data/mockData'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, filiations, persons, unions } from '../data/mockData'
|
||||
import { fetchPersonLife } from '../api/treeApi.js'
|
||||
import LifeTimeline from './LifeTimeline.jsx'
|
||||
import { fetchImmichConfig } from '../api/immichApi.js'
|
||||
import GeoMediaThumbnail from './GeoMediaThumbnail'
|
||||
import ImmichFacePanel from './ImmichFacePanel.jsx'
|
||||
@@ -57,6 +59,28 @@ const PersonCard = ({
|
||||
}) => {
|
||||
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
|
||||
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
|
||||
const [lifeState, setLifeState] = useState({ personId: null, data: null, loading: false, failed: false })
|
||||
|
||||
const lifeRequestedFor = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'vie' || lifeRequestedFor.current === person.id) return undefined
|
||||
lifeRequestedFor.current = person.id
|
||||
if (!treeId || !immichToken) {
|
||||
setLifeState({ personId: person.id, data: getDemoLife(person.id), loading: false, failed: false })
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
// Retour sur l'onglet : on garde la frise affichée pendant qu'elle se rafraîchit
|
||||
setLifeState((prev) => (prev.personId === person.id && prev.data ? prev : { personId: person.id, data: null, loading: true, failed: false }))
|
||||
fetchPersonLife(treeId, person.id, immichToken)
|
||||
.then((data) => { if (!cancelled) setLifeState({ personId: person.id, data, loading: false, failed: false }) })
|
||||
.catch(() => { if (!cancelled) setLifeState({ personId: person.id, data: null, loading: false, failed: true }) })
|
||||
return () => {
|
||||
cancelled = true
|
||||
lifeRequestedFor.current = null
|
||||
}
|
||||
}, [tab, person.id, treeId, immichToken])
|
||||
|
||||
// État local pour les modifications en mode édition
|
||||
const [edits, setEdits] = useState({})
|
||||
@@ -239,6 +263,25 @@ const PersonCard = ({
|
||||
})()
|
||||
const personMedias = getPersonMedias(person.id)
|
||||
|
||||
// Frise de vie : la famille vient de l'arbre déjà chargé, les événements de la route /life,
|
||||
// demandée seulement à l'ouverture de l'onglet (la démo a ses propres données)
|
||||
const siblingsList = (() => {
|
||||
const seen = new Map()
|
||||
for (const parent of parentsList) {
|
||||
for (const child of getChildren(parent.id)) if (child.id !== person.id) seen.set(child.id, child)
|
||||
}
|
||||
return [...seen.values()]
|
||||
})()
|
||||
const lifeFamily = {
|
||||
parents: parentsList,
|
||||
children: childrenList,
|
||||
siblings: siblingsList,
|
||||
unions: personUnions.map((union) => ({
|
||||
...union,
|
||||
partner: persons.find((p) => p.id === (union.partner1Id === person.id ? union.partner2Id : union.partner1Id)) || null,
|
||||
})),
|
||||
}
|
||||
|
||||
// Filter persons for relation search (exclude current person + already linked)
|
||||
const getFilteredPersonsForRelation = (relationType) => {
|
||||
const excludeIds = new Set([person.id])
|
||||
@@ -498,6 +541,9 @@ const PersonCard = ({
|
||||
<button type="button" role="tab" aria-selected={tab === 'medias'} className="pz-tab" onClick={() => setTab('medias')}>
|
||||
{t('Souvenirs')}{personMedias.length > 0 ? ` · ${personMedias.length}` : ''}
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'vie'} className="pz-tab" onClick={() => setTab('vie')}>
|
||||
{t('Vie')}
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'infos'} className="pz-tab" onClick={() => setTab('infos')}>
|
||||
{t('Infos')}
|
||||
</button>
|
||||
@@ -581,6 +627,17 @@ const PersonCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Vie ── */}
|
||||
{tab === 'vie' && (
|
||||
<LifeTimeline
|
||||
person={person}
|
||||
family={lifeFamily}
|
||||
life={lifeState.personId === person.id ? lifeState.data : null}
|
||||
loading={lifeState.personId !== person.id || lifeState.loading}
|
||||
failed={lifeState.personId === person.id && lifeState.failed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Infos ── */}
|
||||
{tab === 'infos' && (
|
||||
<div className="pc-infos">
|
||||
|
||||
@@ -8,17 +8,17 @@ import { buildMediaUrl } from '../utils/mediaToken.js'
|
||||
|
||||
// Démo : noms et lieux restent français, les textes descriptifs sont traduits.
|
||||
export let persons = [
|
||||
{ id: 1, firstName: 'Jean', lastName: 'Dupont', birthYear: 1890, deathYear: 1965, isAlive: false, photo: '/demo/jean.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Menuisier'), nationality: t('Française'), note: t('Patriarche de la famille') },
|
||||
{ id: 2, firstName: 'Marie', lastName: 'Martin', birthYear: 1895, deathYear: 1970, isAlive: false, photo: '/demo/marie.webp', frameType: 'round', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Couturière'), note: t('Atelier de couture rue du Commerce') },
|
||||
{ id: 3, firstName: 'Robert', lastName: 'Dupont', birthYear: 1920, deathYear: 1995, isAlive: false, photo: '/demo/robert.webp', frameType: 'rect', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Instituteur'), note: t("A enseigné 35 ans à l'école communale") },
|
||||
{ id: 4, firstName: 'André', lastName: 'Dupont', birthYear: 1923, deathYear: 2000, isAlive: false, photo: '/demo/andre.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Charpentier') },
|
||||
{ id: 5, firstName: 'Simone', lastName: 'Petit', birthYear: 1925, deathYear: 2010, isAlive: false, photo: '/demo/simone.webp', frameType: 'round', birthPlace: 'Guéret', region: 'Creuse', profession: t('Infirmière') },
|
||||
{ id: 9, firstName: 'Isabelle', lastName: 'Bernard', birthYear: 1928, isAlive: true, photo: '/demo/isabelle.webp', frameType: 'rect', birthPlace: 'Fort-de-France', region: 'Martinique', profession: t('Sage-femme'), note: t('Doyenne de la famille, vit à Brive') },
|
||||
{ id: 6, firstName: 'Jacques', lastName: 'Dupont', birthYear: 1945, deathYear: 2015, isAlive: false, photo: '/demo/jacques.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Journaliste'), note: t('Reporter au Populaire du Centre') },
|
||||
{ id: 7, firstName: 'Philippe', lastName: 'Dupont', birthYear: 1948, isAlive: true, photo: '/demo/philippe.webp', frameType: 'round', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Pharmacien') },
|
||||
{ id: 8, firstName: 'Sophie', lastName: 'Dupont', birthYear: 1960, isAlive: true, photo: '/demo/sophie.webp', frameType: 'rect', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Architecte'), note: t('Cabinet d’architecture à Bordeaux') },
|
||||
{ id: 10, firstName: 'Thomas', lastName: 'Dupont', birthYear: 1962, isAlive: true, photo: '/demo/thomas.webp', frameType: 'polaroid', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Vétérinaire') },
|
||||
{ id: 11, firstName: 'Claire', lastName: 'Dupont', birthYear: 1955, isAlive: true, photo: '/demo/claire.webp', birthPlace: 'Paris', region: 'Ile-de-France', profession: t('Enseignante') },
|
||||
{ id: 1, firstName: 'Jean', sex: 'male', lastName: 'Dupont', birthYear: 1890, deathYear: 1965, isAlive: false, photo: '/demo/jean.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Menuisier'), nationality: t('Française'), note: t('Patriarche de la famille') },
|
||||
{ id: 2, firstName: 'Marie', sex: 'female', lastName: 'Martin', birthYear: 1895, deathYear: 1970, isAlive: false, photo: '/demo/marie.webp', frameType: 'round', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Couturière'), note: t('Atelier de couture rue du Commerce') },
|
||||
{ id: 3, firstName: 'Robert', sex: 'male', lastName: 'Dupont', birthYear: 1920, deathYear: 1995, isAlive: false, photo: '/demo/robert.webp', frameType: 'rect', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Instituteur'), note: t("A enseigné 35 ans à l'école communale") },
|
||||
{ id: 4, firstName: 'André', sex: 'male', lastName: 'Dupont', birthYear: 1923, deathYear: 2000, isAlive: false, photo: '/demo/andre.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Charpentier') },
|
||||
{ id: 5, firstName: 'Simone', sex: 'female', lastName: 'Petit', birthYear: 1925, deathYear: 2010, isAlive: false, photo: '/demo/simone.webp', frameType: 'round', birthPlace: 'Guéret', region: 'Creuse', profession: t('Infirmière') },
|
||||
{ id: 9, firstName: 'Isabelle', sex: 'female', lastName: 'Bernard', birthYear: 1928, isAlive: true, photo: '/demo/isabelle.webp', frameType: 'rect', birthPlace: 'Fort-de-France', region: 'Martinique', profession: t('Sage-femme'), note: t('Doyenne de la famille, vit à Brive') },
|
||||
{ id: 6, firstName: 'Jacques', sex: 'male', lastName: 'Dupont', birthYear: 1945, deathYear: 2015, isAlive: false, photo: '/demo/jacques.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Journaliste'), note: t('Reporter au Populaire du Centre') },
|
||||
{ id: 7, firstName: 'Philippe', sex: 'male', lastName: 'Dupont', birthYear: 1948, isAlive: true, photo: '/demo/philippe.webp', frameType: 'round', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Pharmacien') },
|
||||
{ id: 8, firstName: 'Sophie', sex: 'female', lastName: 'Dupont', birthYear: 1960, isAlive: true, photo: '/demo/sophie.webp', frameType: 'rect', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Architecte'), note: t('Cabinet d’architecture à Bordeaux') },
|
||||
{ id: 10, firstName: 'Thomas', sex: 'male', lastName: 'Dupont', birthYear: 1962, isAlive: true, photo: '/demo/thomas.webp', frameType: 'polaroid', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Vétérinaire') },
|
||||
{ id: 11, firstName: 'Claire', sex: 'female', lastName: 'Dupont', birthYear: 1955, isAlive: true, photo: '/demo/claire.webp', birthPlace: 'Paris', region: 'Ile-de-France', profession: t('Enseignante') },
|
||||
]
|
||||
|
||||
export let medias = [
|
||||
@@ -114,11 +114,36 @@ function mapRemotePersonToLocal(person, current = null) {
|
||||
region: person?.region || null,
|
||||
profession: person?.profession || null,
|
||||
nationality: person?.nationality || null,
|
||||
sex: person?.sex ?? current?.sex ?? null,
|
||||
photo: current?.photo || null,
|
||||
avatarFromImmich: person?.avatarFromImmich ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
// Frise de vie de la démo (onglet Vie), au format de GET /trees/:id/persons/:personId/life. Personnes fictives.
|
||||
const DEMO_LIFE = {
|
||||
3: {
|
||||
person: { birthDatePrecision: null, deathPlace: 'Brive-la-Gaillarde', nickname: null },
|
||||
unions: [
|
||||
{ id: 'u2', startDate: '1943-06-12', startPlace: 'Limoges', startDatePrecision: null },
|
||||
{ id: 'u3', startDate: '1959-01-01', startPlace: 'Brive-la-Gaillarde', startDatePrecision: null },
|
||||
],
|
||||
events: [
|
||||
{ id: 'd1', type: 'birth', sources: t('Registre des naissances de Limoges, acte n° 412') },
|
||||
{ id: 'd2', type: 'baptism', date: '1920-03-21', place: 'Saint-Michel-des-Lions, Limoges' },
|
||||
{ id: 'd3', type: 'education', date: '1938-01-01', dateEnd: '1940-01-01', datePrecision: 'from', description: t('École normale d’instituteurs'), place: 'Limoges' },
|
||||
{ id: 'd4', type: 'military', date: '1940-01-01', dateEnd: '1942-01-01', datePrecision: 'between', notes: t('Affecté au 63e régiment d’infanterie.') },
|
||||
{ id: 'd5', type: 'marriage', unionId: 'u2', sources: t('Registre des mariages de Limoges') },
|
||||
{ id: 'd6', type: 'occupation', date: '1946-01-01', dateEnd: '1981-01-01', datePrecision: 'from', description: t('Instituteur à l’école communale'), place: 'Limoges' },
|
||||
{ id: 'd7', type: 'retirement', date: '1981-01-01' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function getDemoLife(personId) {
|
||||
return DEMO_LIFE[personId] || null
|
||||
}
|
||||
|
||||
export function resetToDemoData() {
|
||||
persons = JSON.parse(JSON.stringify(INITIAL_PERSONS))
|
||||
medias = JSON.parse(JSON.stringify(INITIAL_MEDIAS))
|
||||
|
||||
@@ -296,4 +296,65 @@ export default {
|
||||
'A portrait is already in place. Linking this Immich person will replace the current portrait and delete its file.',
|
||||
'Garder le portrait actuel': 'Keep the current portrait',
|
||||
'Remplacer le portrait et associer': 'Replace the portrait and link',
|
||||
// Onglet Vie (frise de vie)
|
||||
'1 source': '1 source',
|
||||
'{n} sources': '{n} sources',
|
||||
'Pas encore de date connue pour {name}.': 'No known dates for {name} yet.',
|
||||
'Les dates de naissance, d’union et de décès, et celles de sa famille, apparaîtront ici.': 'Dates of birth, marriage and death, and those of their family, will show up here.',
|
||||
'Les autres événements n’ont pas pu être chargés.': 'The other events could not be loaded.',
|
||||
'Vie': 'Life',
|
||||
'Registre des naissances de Limoges, acte n° 412': 'Limoges birth register, entry no. 412',
|
||||
'École normale d’instituteurs': 'Teacher training college',
|
||||
'Affecté au 63e régiment d’infanterie.': 'Assigned to the 63rd infantry regiment.',
|
||||
'Registre des mariages de Limoges': 'Limoges marriage register',
|
||||
'Instituteur à l’école communale': 'Teacher at the village school',
|
||||
'Baptême': 'Baptism',
|
||||
'Inhumation': 'Burial',
|
||||
'Crémation': 'Cremation',
|
||||
'Résidence': 'Residence',
|
||||
'Études': 'Education',
|
||||
'Religion': 'Religion',
|
||||
'Confirmation': 'Confirmation',
|
||||
'Première communion': 'First communion',
|
||||
'Bar-mitzvah': 'Bar mitzvah',
|
||||
'Bat-mitzvah': 'Bat mitzvah',
|
||||
'Ordination': 'Ordination',
|
||||
'Émigration': 'Emigration',
|
||||
'Immigration': 'Immigration',
|
||||
'Naturalisation': 'Naturalisation',
|
||||
'Recensement': 'Census',
|
||||
'Biens': 'Property',
|
||||
'Retraite': 'Retirement',
|
||||
'Testament': 'Will',
|
||||
'Succession': 'Probate',
|
||||
'Santé': 'Health',
|
||||
'Divorce': 'Divorce',
|
||||
'Demande de divorce': 'Divorce filed',
|
||||
'Annulation du mariage': 'Annulment',
|
||||
'Fiançailles': 'Engagement',
|
||||
'Bans du mariage': 'Marriage banns',
|
||||
'Contrat de mariage': 'Marriage contract',
|
||||
'Autorisation de mariage': 'Marriage licence',
|
||||
'Accord de mariage': 'Marriage settlement',
|
||||
'vers {year}': 'about {year}',
|
||||
'avant {year}': 'before {year}',
|
||||
'après {year}': 'after {year}',
|
||||
'entre {year} et {end}': 'between {year} and {end}',
|
||||
'jusqu’en {end}': 'until {end}',
|
||||
'jusqu’en {year}': 'until {year}',
|
||||
'Décès de son père {name}': 'Death of their father, {name}',
|
||||
'Décès de sa mère {name}': 'Death of their mother, {name}',
|
||||
'Décès de {name}, son parent': 'Death of {name}, a parent',
|
||||
'Naissance de son frère {name}': 'Birth of a brother, {name}',
|
||||
'Naissance de sa sœur {name}': 'Birth of a sister, {name}',
|
||||
'Naissance de {name}, sa fratrie': 'Birth of a sibling, {name}',
|
||||
'Naissance à {place}': 'Born in {place}',
|
||||
'Conjoint inconnu': 'Unknown partner',
|
||||
'Union avec {name}': 'Partnership with {name}',
|
||||
'Épouse {name}': 'Marries {name}',
|
||||
'Fin de l’union avec {name}': 'End of the relationship with {name}',
|
||||
'Décès de {name}': 'Death of {name}',
|
||||
'Événement': 'Event',
|
||||
'Naissance de {name}': 'Birth of {name}',
|
||||
'Décès à {place}': 'Died in {place}',
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Frise de vie d'une personne (onglet Vie de la fiche).
|
||||
//
|
||||
// Trois sources, rangées par date : la naissance, les unions et le décès, qui servent de grands repères ;
|
||||
// les événements importés ou saisis (baptême, métiers, service militaire…) ; et la famille proche que
|
||||
// l'arbre connaît déjà (naissance des enfants et des frères et sœurs, décès des parents, du conjoint, d'un
|
||||
// enfant), limitée aux années où la personne a vécu. Une fiche sans aucun événement a donc déjà une frise.
|
||||
|
||||
import { t, dateLocale } from '../i18n/index.js'
|
||||
import { formatPlace } from './place.js'
|
||||
|
||||
const EVENT_LABELS = {
|
||||
baptism: t('Baptême'),
|
||||
burial: t('Inhumation'),
|
||||
cremation: t('Crémation'),
|
||||
residence: t('Résidence'),
|
||||
occupation: t('Métier'),
|
||||
education: t('Études'),
|
||||
graduation: t('Diplôme'),
|
||||
degree: t('Diplôme'),
|
||||
religion: t('Religion'),
|
||||
confirmation: t('Confirmation'),
|
||||
first_communion: t('Première communion'),
|
||||
bar_mitzvah: t('Bar-mitzvah'),
|
||||
bat_mitzvah: t('Bat-mitzvah'),
|
||||
ordination: t('Ordination'),
|
||||
adoption: t('Adoption'),
|
||||
emigration: t('Émigration'),
|
||||
immigration: t('Immigration'),
|
||||
naturalization: t('Naturalisation'),
|
||||
census: t('Recensement'),
|
||||
title: t('Titre'),
|
||||
property: t('Biens'),
|
||||
retirement: t('Retraite'),
|
||||
will: t('Testament'),
|
||||
probate: t('Succession'),
|
||||
military: t('Service militaire'),
|
||||
medical: t('Santé'),
|
||||
divorce: t('Divorce'),
|
||||
divorce_filed: t('Demande de divorce'),
|
||||
annulment: t('Annulation du mariage'),
|
||||
engagement: t('Fiançailles'),
|
||||
marriage_banns: t('Bans du mariage'),
|
||||
marriage_contract: t('Contrat de mariage'),
|
||||
marriage_license: t('Autorisation de mariage'),
|
||||
marriage_settlement: t('Accord de mariage'),
|
||||
}
|
||||
|
||||
const yearOf = (value) => {
|
||||
const match = String(value ?? '').match(/^(\d{4})/)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
const fullName = (p) => [p?.firstName, p?.lastName].filter(Boolean).join(' ')
|
||||
|
||||
// « 12 mars » pour une date complète, rien pour une année seule (stockée au 1er janvier)
|
||||
function dayMonth(value) {
|
||||
const match = String(value ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/)
|
||||
if (!match || (match[2] === '01' && match[3] === '01')) return ''
|
||||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
|
||||
return date.toLocaleDateString(dateLocale, { day: 'numeric', month: 'long', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
// La date telle qu'on la dit : « vers 1879 », « entre 1914 et 1918 », « jusqu'en 1981 »
|
||||
export function describeDate(date, end, precision) {
|
||||
const year = yearOf(date)
|
||||
const endYear = yearOf(end)
|
||||
if (!year) return ''
|
||||
switch (precision) {
|
||||
case 'about':
|
||||
case 'estimated':
|
||||
case 'calculated':
|
||||
return t('vers {year}', { year })
|
||||
case 'before': return t('avant {year}', { year })
|
||||
case 'after': return t('après {year}', { year })
|
||||
case 'between': return endYear ? t('entre {year} et {end}', { year, end: endYear }) : t('après {year}', { year })
|
||||
case 'from': return endYear ? t('jusqu’en {end}', { end: endYear }) : t('depuis {year}', { year })
|
||||
case 'to': return t('jusqu’en {year}', { year })
|
||||
default: return dayMonth(date)
|
||||
}
|
||||
}
|
||||
|
||||
const ageAt = (birthYear, year) => (birthYear && year && year > birthYear ? t('{age} ans', { age: year - birthYear }) : '')
|
||||
const joinMeta = (...parts) => parts.filter(Boolean).join(' · ')
|
||||
|
||||
const parentWord = (p) => {
|
||||
if (p.sex === 'male' || p.sex === 'M') return t('Décès de son père {name}', { name: p.firstName })
|
||||
if (p.sex === 'female' || p.sex === 'F') return t('Décès de sa mère {name}', { name: p.firstName })
|
||||
return t('Décès de {name}, son parent', { name: p.firstName })
|
||||
}
|
||||
|
||||
const siblingWord = (p) => {
|
||||
if (p.sex === 'male' || p.sex === 'M') return t('Naissance de son frère {name}', { name: p.firstName })
|
||||
if (p.sex === 'female' || p.sex === 'F') return t('Naissance de sa sœur {name}', { name: p.firstName })
|
||||
return t('Naissance de {name}, sa fratrie', { name: p.firstName })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param person personne de la fiche (birthDate, deathDate, birthPlace, birthYear, deathYear)
|
||||
* @param family { parents, children, siblings, unions: [{ id, partner, startYear, endYear, unionType }] }
|
||||
* @param life réponse de GET /trees/:id/persons/:personId/life, ou null tant qu'elle n'est pas là
|
||||
* @returns entrées triées { key, year, kind: 'mile' | 'minor' | 'fam', title, meta, note, sources }
|
||||
*/
|
||||
export function buildLifeTimeline(person, family = {}, life = null) {
|
||||
const details = life?.person || {}
|
||||
const lifeUnions = new Map((life?.unions || []).map((u) => [String(u.id), u]))
|
||||
const events = life?.events || []
|
||||
const birthYear = yearOf(person.birthDate) ?? person.birthYear ?? null
|
||||
const deathYear = yearOf(person.deathDate) ?? person.deathYear ?? null
|
||||
const lastYear = deathYear ?? new Date().getFullYear()
|
||||
const within = (year) => year && (!birthYear || year >= birthYear) && year <= lastYear
|
||||
const sourcesOf = (type, unionId = null) => events.find((e) => e.type === type && (unionId ? String(e.unionId) === String(unionId) : !e.unionId))
|
||||
const out = []
|
||||
|
||||
if (birthYear) {
|
||||
const place = formatPlace(person.birthPlace || '')
|
||||
const extra = sourcesOf('birth')
|
||||
out.push({
|
||||
key: 'birth', year: birthYear, order: 0, kind: 'mile',
|
||||
title: place ? t('Naissance à {place}', { place: place.split(',')[0] }) : t('Naissance'),
|
||||
meta: describeDate(person.birthDate, details.birthDateEnd, details.birthDatePrecision),
|
||||
note: extra?.notes || '', sources: extra?.sources || '',
|
||||
})
|
||||
}
|
||||
|
||||
for (const union of family.unions || []) {
|
||||
const detail = lifeUnions.get(String(union.id)) || {}
|
||||
const year = yearOf(detail.startDate) ?? union.startYear
|
||||
const partnerName = union.partner ? fullName(union.partner) : t('Conjoint inconnu')
|
||||
const place = formatPlace(detail.startPlace || '')
|
||||
const extra = sourcesOf('marriage', union.id)
|
||||
out.push({
|
||||
key: `union-${union.id}`, year, order: 2, kind: 'mile',
|
||||
title: union.unionType === 'union' ? t('Union avec {name}', { name: partnerName }) : t('Épouse {name}', { name: partnerName }),
|
||||
meta: joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year)),
|
||||
note: extra?.notes || '', sources: extra?.sources || '',
|
||||
})
|
||||
const hasEndEvent = events.some((e) => String(e.unionId) === String(union.id) && ['divorce', 'annulment'].includes(e.type))
|
||||
if (union.endYear && !hasEndEvent) {
|
||||
out.push({ key: `union-end-${union.id}`, year: union.endYear, order: 3, kind: 'minor', title: t('Fin de l’union avec {name}', { name: union.partner?.firstName || partnerName }), meta: ageAt(birthYear, union.endYear) })
|
||||
}
|
||||
if (union.partner && within(yearOf(union.partner.deathDate) ?? union.partner.deathYear)) {
|
||||
const year2 = yearOf(union.partner.deathDate) ?? union.partner.deathYear
|
||||
out.push({ key: `partner-death-${union.id}`, year: year2, order: 4, kind: 'fam', title: t('Décès de {name}', { name: union.partner.firstName }) })
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (['birth', 'death', 'marriage'].includes(event.type)) continue
|
||||
const year = yearOf(event.date)
|
||||
let title = event.type === 'custom' ? (event.label || t('Événement')) : (EVENT_LABELS[event.type] || event.label || t('Événement'))
|
||||
// Métier, études, diplôme, titre : la description dit déjà ce que c'est (« Instituteur », « École normale »)
|
||||
if (['occupation', 'education', 'graduation', 'degree', 'title'].includes(event.type) && event.description) title = event.description
|
||||
else if (event.description && event.type !== 'custom') title = `${title}, ${event.description}`
|
||||
else if (event.type === 'custom' && event.description) title = `${title}, ${event.description}`
|
||||
if (event.unionId) {
|
||||
const union = (family.unions || []).find((u) => String(u.id) === String(event.unionId))
|
||||
if (union?.partner && ['divorce', 'annulment', 'engagement', 'marriage_contract'].includes(event.type)) title = `${title} (${union.partner.firstName})`
|
||||
}
|
||||
out.push({
|
||||
key: `event-${event.id}`, year, order: 1, kind: 'minor', title,
|
||||
meta: joinMeta(formatPlace(event.place || ''), describeDate(event.date, event.dateEnd, event.datePrecision), ageAt(birthYear, year)),
|
||||
note: event.notes || '', sources: event.sources || '',
|
||||
})
|
||||
}
|
||||
|
||||
for (const child of family.children || []) {
|
||||
const year = yearOf(child.birthDate) ?? child.birthYear
|
||||
if (within(year)) out.push({ key: `child-${child.id}`, year, order: 5, kind: 'fam', title: t('Naissance de {name}', { name: child.firstName }) })
|
||||
const death = yearOf(child.deathDate) ?? child.deathYear
|
||||
if (within(death) && death !== deathYear) out.push({ key: `child-death-${child.id}`, year: death, order: 6, kind: 'fam', title: t('Décès de {name}', { name: child.firstName }) })
|
||||
}
|
||||
for (const sibling of family.siblings || []) {
|
||||
const year = yearOf(sibling.birthDate) ?? sibling.birthYear
|
||||
if (within(year) && year !== birthYear) out.push({ key: `sibling-${sibling.id}`, year, order: 5, kind: 'fam', title: siblingWord(sibling) })
|
||||
}
|
||||
for (const parent of family.parents || []) {
|
||||
const year = yearOf(parent.deathDate) ?? parent.deathYear
|
||||
if (within(year)) out.push({ key: `parent-death-${parent.id}`, year, order: 6, kind: 'fam', title: parentWord(parent) })
|
||||
}
|
||||
|
||||
if (deathYear) {
|
||||
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)),
|
||||
note: extra?.notes || '', sources: extra?.sources || '',
|
||||
})
|
||||
}
|
||||
|
||||
// Les entrées sans date restent en bas, dans l'ordre où elles sont arrivées
|
||||
return out
|
||||
.map((entry, index) => ({ ...entry, index }))
|
||||
.sort((a, b) => (a.year ?? Infinity) - (b.year ?? Infinity) || a.order - b.order || a.index - b.index)
|
||||
.map(({ order, index, ...entry }) => entry) // eslint-disable-line no-unused-vars
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLifeTimeline, describeDate } from './lifeTimeline.js'
|
||||
|
||||
const robert = { id: 3, firstName: 'Robert', birthYear: 1920, deathYear: 1995, birthDate: '1920-03-12', deathDate: '1995-05-01', birthPlace: 'Limoges,87085,Haute-Vienne,,France,' }
|
||||
const simone = { id: 5, firstName: 'Simone', lastName: 'Petit', birthYear: 1925, deathYear: 2010 }
|
||||
const family = {
|
||||
parents: [{ id: 1, firstName: 'Jean', sex: 'male', deathYear: 1965 }, { id: 2, firstName: 'Marie', sex: 'female', deathYear: 2001 }],
|
||||
children: [{ id: 6, firstName: 'Jacques', birthYear: 1945, deathYear: 2015 }],
|
||||
siblings: [{ id: 4, firstName: 'André', sex: 'male', birthYear: 1923 }, { id: 9, firstName: 'Paul', sex: 'male', birthYear: 1915 }],
|
||||
unions: [{ id: 'u2', partner: simone, startYear: 1943, endYear: 1958, unionType: 'mariage' }],
|
||||
}
|
||||
const life = {
|
||||
person: { deathPlace: 'Brive-la-Gaillarde', birthDatePrecision: null },
|
||||
unions: [{ id: 'u2', startDate: '1943-06-12', startPlace: 'Limoges' }],
|
||||
events: [
|
||||
{ id: 'e1', type: 'birth', sources: 'Registre A\nRegistre B' },
|
||||
{ id: 'e2', type: 'occupation', date: '1946-01-01', dateEnd: '1981-01-01', datePrecision: 'from', description: 'Instituteur' },
|
||||
{ id: 'e3', type: 'custom', label: 'Militaire', date: '1940-01-01', dateEnd: '1942-01-01', datePrecision: 'between', notes: 'Verdun' },
|
||||
{ id: 'e4', type: 'residence', place: 'Rue des Écoles' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('frise de vie', () => {
|
||||
const entries = buildLifeTimeline(robert, family, life)
|
||||
const titles = entries.map((e) => e.title)
|
||||
|
||||
it('range tout par date, les entrées sans date en bas', () => {
|
||||
expect(titles).toEqual([
|
||||
'Naissance à Limoges', 'Naissance de son frère André', 'Militaire', 'Épouse Simone Petit', 'Naissance de Jacques',
|
||||
'Instituteur', 'Fin de l’union avec Simone', 'Décès de son père Jean', 'Décès à Brive-la-Gaillarde', 'Résidence',
|
||||
])
|
||||
})
|
||||
|
||||
it('grands repères, précisions, âge et sources', () => {
|
||||
const birth = entries[0]
|
||||
expect(birth).toMatchObject({ kind: 'mile', year: 1920, sources: 'Registre A\nRegistre B' })
|
||||
expect(entries.find((e) => e.title === 'Instituteur').meta).toBe('jusqu’en 1981 · 26 ans')
|
||||
expect(entries.find((e) => e.title === 'Militaire')).toMatchObject({ kind: 'minor', meta: 'entre 1940 et 1942 · 20 ans', note: 'Verdun' })
|
||||
expect(entries.find((e) => e.title === 'Épouse Simone Petit')).toMatchObject({ kind: 'mile', meta: 'Limoges · 23 ans' })
|
||||
})
|
||||
|
||||
it('famille limitée à sa vie : frère aîné, mère morte après lui, enfant mort après lui écartés', () => {
|
||||
expect(titles.join()).not.toMatch(/Paul|Marie|Décès de Jacques/)
|
||||
})
|
||||
|
||||
it('sans données de la route, la famille et les repères suffisent', () => {
|
||||
const bare = buildLifeTimeline({ id: 1, firstName: 'X', birthYear: 1900 }, { children: [{ id: 2, firstName: 'Y', birthYear: 1930 }] }, null)
|
||||
expect(bare.map((e) => e.title)).toEqual(['Naissance', 'Naissance de Y'])
|
||||
expect(buildLifeTimeline({ id: 1, firstName: 'X' }, {}, null)).toEqual([])
|
||||
})
|
||||
|
||||
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')
|
||||
expect(describeDate('1920-03-12', null, null)).toBe('12 mars')
|
||||
expect(describeDate('1920-01-01', null, null)).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user