Merge pull request #190 from IliesAllali/feat/vie-couples

Life tab: neutral couple label, couple date and place, names link to people
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-28 08:28:51 +01:00
commit ba6f63ff09
10 files changed
+152 -38

No files matched your search

+5
View File
@@ -143,6 +143,9 @@ const patchUnionSchema = z
partner2PersonId: z.string().min(1).optional().nullable(),
unionType: z.string().max(50).optional().nullable(),
startDate: z.string().regex(isoDateRegex).optional().nullable(),
// Lieu et précision de la date du couple, saisis dans l'onglet Vie (« vers 1980 »)
startPlace: z.string().trim().max(300).optional().nullable(),
startDatePrecision: z.enum(['about', 'estimated', 'calculated', 'before', 'after']).optional().nullable(),
endDate: z.string().regex(isoDateRegex).optional().nullable(),
displayOrder: z.number().int().positive().optional(),
})
@@ -1780,6 +1783,8 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
},
data: {
...unionPrecisionResets(existingUnion, { startDate }),
...(payload.data.startDatePrecision !== undefined ? { startDatePrecision: payload.data.startDatePrecision } : {}),
startPlace: payload.data.startPlace !== undefined ? payload.data.startPlace || null : undefined,
partner1PersonId: payload.data.partner1PersonId,
partner2PersonId: payload.data.partner2PersonId,
unionType: payload.data.unionType,
+5
View File
@@ -3544,6 +3544,11 @@ function App() {
onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)}
onUploadMomentFile={tree.canEditCurrentTree || tree.canSubmitContribution ? media.uploadMomentFile : undefined}
onMomentSaved={media.refreshMomentMedia}
onOpenPerson={async (personId) => {
const target = persons.find((p) => String(p.id) === String(personId))
if (!target || !(await chooseSelectedPerson(target))) return
galaxyZoomApiRef.current?.focusPerson?.(target.id)
}}
onQuickEdit={tree.canEditCurrentTree && userRole.isAdmin && !isDemoMode ? handleEditToggle : undefined}
/>
)}
+13
View File
@@ -216,6 +216,19 @@ export async function createParentChildLink(treeId, payload, token) {
return data.link
}
// Type, date et lieu d'un couple, depuis l'onglet Vie
export async function updateUnion(treeId, unionId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.union
}
export async function deleteUnion(treeId, unionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'DELETE',
+10
View File
@@ -79,3 +79,13 @@
.lt-notice { margin: 0 0 10px; }
.lt-add { margin-top: 14px; }
.pc-empty .lt-add { margin-top: 12px; }
/* Prénom d'un proche dans la frise : ouvre sa fiche (Patch, 28/10) */
.lt-person {
appearance: none; border: 0; background: none; padding: 0; margin: 0; cursor: pointer;
font: inherit; color: var(--pz-a700); border-radius: 4px;
text-decoration: underline; text-decoration-color: var(--pz-a200); text-underline-offset: 3px;
transition: text-decoration-color 160ms ease;
}
@media (hover: hover) and (pointer: fine) { .lt-person:hover { text-decoration-color: var(--pz-a500); } }
.lt-person:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
+27 -8
View File
@@ -26,6 +26,31 @@ function Sources({ text }) {
)
}
// Titre d'une ligne : le prénom d'un proche ouvre sa fiche ; le reste du titre ouvre la modification du moment
// (administrateur). Un bouton ne peut pas en contenir un autre : les deux sont côte à côte.
function Title({ entry, editable, onEdit, onOpenPerson }) {
const editLabel = t('Modifier « {title} »', { title: entry.title })
const link = onOpenPerson ? entry.link : null
const at = link ? entry.title.indexOf(link.name) : -1
if (at < 0) {
return editable ? (
<button type="button" className="lt-title lt-title--edit" onClick={() => onEdit?.(entry)} aria-label={editLabel}>{entry.title}</button>
) : <div className="lt-title">{entry.title}</div>
}
const before = entry.title.slice(0, at)
const after = entry.title.slice(at + link.name.length)
const edit = (text) => (editable && text.trim()
? <button type="button" className="lt-title--edit" onClick={() => onEdit?.(entry)} aria-label={editLabel}>{text}</button>
: text)
return (
<div className="lt-title">
{edit(before.trimEnd())}{/\s$/.test(before) ? ' ' : ''}
<button type="button" className="lt-person" onClick={() => onOpenPerson(link.personId)}>{link.name}</button>
{before.trim() ? after : edit(after)}
</div>
)
}
const FILE_LABELS = { video: t('Vidéo'), audio: t('Enregistrement'), document: t('Document') }
// Souvenirs rattachés au moment : les photos en vignettes, le reste (vidéo, acte, voix) en pastilles
@@ -59,7 +84,7 @@ function Attachments({ medias, onOpen }) {
export default function LifeTimeline({
person, family, life, loading = false, failed = false, medias = [],
canAdd = false, canEdit = false, notice = '', onAdd, onEdit, onOpenMedia,
canAdd = false, canEdit = false, notice = '', onAdd, onEdit, onOpenMedia, onOpenPerson,
}) {
const entries = buildLifeTimeline(person, family, life)
const mediaById = new Map(medias.map((m) => [String(m.id), m]))
@@ -96,13 +121,7 @@ export default function LifeTimeline({
<li key={entry.key} className={`lt-ev lt-ev--${entry.kind}`}>
<span className="lt-year">{entry.year ?? ''}</span>
<div className="lt-body">
{editable ? (
<button type="button" className="lt-title lt-title--edit" onClick={() => onEdit?.(entry)} aria-label={t('Modifier « {title} »', { title: entry.title })}>
{entry.title}
</button>
) : (
<div className="lt-title">{entry.title}</div>
)}
<Title entry={entry} editable={editable} onEdit={onEdit} onOpenPerson={onOpenPerson} />
{entry.meta && <div className="lt-meta">{entry.meta}</div>}
{entry.note && <p className="lt-note">{entry.note}</p>}
{entry.sources && <Sources text={entry.sources} />}
+40 -19
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { X, Paperclip, Images, FileText, Mic, Play, Check } from 'lucide-react'
import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import { createLifeEvent, updateLifeEvent, deleteLifeEvent } from '../api/treeApi.js'
import { createLifeEvent, updateLifeEvent, deleteLifeEvent, updateUnion } from '../api/treeApi.js'
import { DESCRIBED_TYPES, eventTitle } from '../utils/lifeTimeline.js'
import { inferMediaTypeFromMimeType } from '../utils/mediaUpload.js'
import { readContributorName, saveContributorName } from '../utils/contributorName.js'
@@ -14,7 +14,8 @@ import { fetchImmichConfig } from '../api/immichApi.js'
// Fiche « Ajouter un moment » de l'onglet Vie : quoi, quand, où, et des souvenirs (photo, vidéo, acte en
// PDF, enregistrement). La date exacte, la fin, la source et la note restent dans « Plus de détails ».
// Trois usages : ajouter (administrateur ou famille), modifier un moment (administrateur), et compléter une
// naissance, un mariage ou un décès avec ses documents (administrateur, la date se change dans Infos).
// naissance ou un décès avec ses documents (administrateur, la date se change dans Infos). Pour un couple, la
// fiche dit aussi si c'est un mariage, et en garde la date et le lieu (onglet Vie, 28/10).
// Suggestions touchables : le texte exact devient le type correspondant (exporté en GEDCOM sous sa balise)
const SUGGESTIONS = [
@@ -36,15 +37,19 @@ function FileThumb({ type, url }) {
return <span className="ms-file-icon"><Icon size={18} strokeWidth={1.8} /></span>
}
export default function MomentSheet({ person, entry = null, treeId, token, medias = [], isAdmin = false, onUploadFile, onClose, onSaved, onDelete }) {
export default function MomentSheet({ person, entry = null, treeId, token, medias = [], isAdmin = false, onUploadFile, onClose, onSaved, onDelete, onUnionSaved }) {
const milestone = entry?.milestone || null
const event = entry?.event || null
// Couple : sa date et son lieu vivent sur le couple, pas sur le moment
const union = milestone && entry?.union ? entry.union : null
const editing = Boolean(event)
const dated = union ? { date: union.startDate, datePrecision: union.startDatePrecision, place: union.startPlace } : event
const [what, setWhat] = useState(() => (event && !milestone ? eventTitle(event) : ''))
const [year, setYear] = useState(() => yearOf(event?.date))
const [approx, setApprox] = useState(() => ['about', 'estimated', 'calculated', 'between'].includes(event?.datePrecision))
const [place, setPlace] = useState(event?.place || '')
const [day, setDay] = useState(() => exactDay(event?.date))
const [married, setMarried] = useState(() => Boolean(union?.married))
const [year, setYear] = useState(() => yearOf(dated?.date))
const [approx, setApprox] = useState(() => ['about', 'estimated', 'calculated', 'between'].includes(dated?.datePrecision))
const [place, setPlace] = useState(dated?.place || '')
const [day, setDay] = useState(() => exactDay(dated?.date))
const [endYear, setEndYear] = useState(() => yearOf(event?.dateEnd))
const [sources, setSources] = useState(event?.sources || '')
const [notes, setNotes] = useState(event?.notes || '')
@@ -68,6 +73,8 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
const created = useRef(null)
const fileRef = useRef(null)
const hasDetails = Boolean(day || endYear || sources || notes)
// Les champs « Quand ? » et « Où ? » : un moment à soi, ou un couple
const withWhen = !milestone || Boolean(union)
useEffect(() => { firstFieldRef.current?.focus() }, [])
useEffect(() => {
@@ -151,9 +158,17 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
// Un fichier refusé au milieu de l'envoi : le moment est déjà créé, on ne le recrée pas en réessayant
let eventId = event?.id || created.current?.id || null
let status = created.current?.status || 'approved'
if (union) {
const start = day || (year ? `${year}-01-01` : null)
const unionType = married ? 'mariage' : 'union'
await updateUnion(treeId, union.id, { unionType, startDate: start, startDatePrecision: start && approx && !day ? 'about' : null, startPlace: place.trim() || null }, token)
onUnionSaved?.(union.id, { unionType, startYear: start ? Number(start.slice(0, 4)) : null })
}
// Un couple qui n'est pas un mariage, sans source, note ni souvenir : rien à garder à côté de lui
const needsRow = !union || married || Boolean(sources.trim() || notes.trim() || linkedIds.length || files.length)
if (editing) {
await updateLifeEvent(treeId, eventId, buildPayload(), token)
} else if (!created.current) {
} else if (!created.current && needsRow) {
const response = await createLifeEvent(treeId, person.id, buildPayload(), token)
eventId = response?.event?.id
status = response?.event?.status || 'approved'
@@ -204,14 +219,20 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
</button>
<div className="pz-modal-head">
<h2 id="ms-title" className="pz-title ms-title">{title}</h2>
{milestone && <p className="pz-small">{t('Ajoutez ses documents, un acte en PDF ou une photo, et la source. La date et le lieu se changent dans l’onglet Infos.')}</p>}
{milestone && !union && <p className="pz-small">{t('Ajoutez ses documents, un acte en PDF ou une photo, et la source. La date et le lieu se changent dans l’onglet Infos.')}</p>}
{!isAdmin && <p className="pz-small">{t('Ce que vous ajoutez apparaîtra dans sa vie quand la personne qui gère l’arbre l’aura accepté.')}</p>}
</div>
<form className="ms-form" onSubmit={(e) => { e.preventDefault(); save() }}>
{!milestone && (
{union && (
<label className="ms-check ms-married">
<input type="checkbox" checked={married} onChange={(e) => setMarried(e.target.checked)} />
{t('C’était un mariage')}
</label>
)}
{withWhen && (
<>
<div className="pz-field">
{!milestone && <div className="pz-field">
<label htmlFor="ms-what">{t('Quoi ?')}</label>
<input id="ms-what" ref={firstFieldRef} value={what} maxLength={120} onChange={(e) => setWhat(e.target.value)} placeholder={t('Service militaire, premier emploi, départ pour Paris…')} />
{!editing && (
@@ -221,12 +242,12 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
))}
</div>
)}
</div>
</div>}
<div className="ms-row">
<div className="pz-field ms-year">
<label htmlFor="ms-year">{t('Quand ?')}</label>
<input id="ms-year" inputMode="numeric" maxLength={4} value={year} onChange={(e) => setYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!yearValid || undefined} disabled={Boolean(day)} />
<input id="ms-year" ref={union ? firstFieldRef : undefined} inputMode="numeric" maxLength={4} value={year} onChange={(e) => setYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!yearValid || undefined} disabled={Boolean(day)} />
</div>
<label className="ms-check">
<input type="checkbox" checked={approx} onChange={(e) => setApprox(e.target.checked)} disabled={Boolean(day) && !endYear} />
@@ -264,7 +285,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
)}
<div className="ms-file-actions">
{isAdmin && immichConfigured && <button type="button" className="ms-file-add" disabled={busy} onClick={() => setPickingImmich((value) => !value)}>{t('Depuis Immich')}</button>}
<button type="button" ref={milestone ? firstFieldRef : undefined} className="ms-file-add" onClick={() => fileRef.current?.click()} disabled={busy || !onUploadFile}>
<button type="button" ref={milestone && !union ? firstFieldRef : undefined} className="ms-file-add" onClick={() => fileRef.current?.click()} disabled={busy || !onUploadFile}>
<Paperclip size={15} strokeWidth={1.8} />
{t('Ajouter un fichier')}
</button>
@@ -299,19 +320,19 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
</div>
)}
<details className="ms-more" open={hasDetails || milestone ? true : undefined}>
<summary>{milestone ? t('Source et note') : t('Plus de détails')}</summary>
<details className="ms-more" open={hasDetails || (milestone && !union) ? true : undefined}>
<summary>{milestone && !union ? t('Source et note') : t('Plus de détails')}</summary>
<div className="ms-more-body">
{!milestone && (
{withWhen && (
<div className="ms-row">
<div className="pz-field">
<label htmlFor="ms-day">{t('Date exacte')}</label>
<input id="ms-day" type="date" value={day} onChange={(e) => { setDay(e.target.value); if (e.target.value) setYear(e.target.value.slice(0, 4)) }} />
</div>
<div className="pz-field ms-year">
{!union && <div className="pz-field ms-year">
<label htmlFor="ms-end">{t('Jusqu’en')}</label>
<input id="ms-end" inputMode="numeric" maxLength={4} value={endYear} onChange={(e) => setEndYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!endValid || undefined} />
</div>
</div>}
</div>
)}
<div className="pz-field">
+8
View File
@@ -82,6 +82,8 @@ const PersonCard = ({
onMoveSibling,
onUploadMomentFile,
onMomentSaved,
// Prénom d'un proche touché dans l'onglet Vie : sa fiche s'ouvre
onOpenPerson,
// Suppression annulable (App, hooks/useUndoable.js)
onUndoable,
saveOnLeaveRef = null,
@@ -951,6 +953,7 @@ const PersonCard = ({
onAdd={() => { setLifeNotice(''); setMomentSheet({ entry: null }) }}
onEdit={(entry) => { setLifeNotice(''); setMomentSheet({ entry }) }}
onOpenMedia={(media) => onMediaSelect?.(media, person)}
onOpenPerson={onOpenPerson}
/>
)}
@@ -1291,6 +1294,11 @@ const PersonCard = ({
undo: () => setLifeRevision((r) => r + 1),
})
} : undefined}
onUnionSaved={(unionId, { unionType, startYear }) => {
// Le couple du graphe suit tout de suite (onglet Infos, frise), sans recharger l'arbre
const union = personUnions.find((u) => String(u.id) === String(unionId))
if (union) Object.assign(union, { unionType, startYear })
}}
onSaved={({ pending, uploaded }) => {
setMomentSheet(null)
if (pending) setLifeNotice(t('Merci, c’est envoyé. Le moment apparaîtra dans sa vie quand la personne qui gère l’arbre l’aura accepté.'))
+3 -2
View File
@@ -428,8 +428,9 @@ export default {
'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}',
'Mariage avec {name}': 'Married {name}',
'En couple avec {name}': 'Together with {name}',
'C’était un mariage': 'They were married',
'Fin de l’union avec {name}': 'End of the relationship with {name}',
'Décès de {name}': 'Death of {name}',
'Événement': 'Event',
+17 -6
View File
@@ -93,6 +93,9 @@ export function describeDate(date, end, precision) {
const ageAt = (birthYear, year) => (birthYear && year && year > birthYear ? t('{age} ans', { age: year - birthYear }) : '')
const joinMeta = (...parts) => parts.filter(Boolean).join(' · ')
// Le prénom d'un proche, cliquable dans la frise : il ouvre sa fiche
const linkTo = (p, name = firstNameLabel(p)) => (p?.id != null && name ? { personId: p.id, name } : null)
const parentWord = (p) => {
if (p.sex === 'male' || p.sex === 'M') return t('Décès de son père {name}', { name: firstNameLabel(p) })
if (p.sex === 'female' || p.sex === 'F') return t('Décès de sa mère {name}', { name: firstNameLabel(p) })
@@ -150,9 +153,17 @@ export function buildLifeTimeline(person, family = {}, life = null) {
const childYears = (family.children || []).filter((c) => childIds.has(String(c.id))).map((c) => yearOf(c.birthDate) ?? c.birthYear).filter(within)
const partnerDeath = union.partner ? yearOf(union.partner.deathDate) ?? union.partner.deathYear : null
const anchor = year ? null : (childYears.length ? Math.min(...childYears) : null) ?? union.endYear ?? (within(partnerDeath) ? partnerDeath : null)
// Un couple naît « mariage » par défaut dès qu'un enfant a deux parents : « Mariage » seulement quand c'est
// dit (une date, un lieu, des documents de mariage), sinon « En couple avec », qui vaut pour toutes les familles
const married = union.unionType !== 'union' && Boolean(year || detail.startPlace || extra)
out.push({
key: `union-${union.id}`, year, sortYear: anchor, order: year ? 2 : 1, kind: 'mile',
title: union.unionType === 'union' ? t('Union avec {name}', { name: partnerName }) : t('Épouse {name}', { name: partnerName }),
title: married ? t('Mariage avec {name}', { name: partnerName }) : t('En couple avec {name}', { name: partnerName }),
link: union.partner ? linkTo(union.partner, partnerName) : null,
union: {
id: union.id, married, startDate: detail.startDate || (year ? String(year) : ''),
startDatePrecision: detail.startDatePrecision || null, startPlace: detail.startPlace || '',
},
meta: year
? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year))
: joinMeta(place, t('date inconnue')),
@@ -165,7 +176,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
}
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 }) })
out.push({ key: `partner-death-${union.id}`, year: year2, order: 4, kind: 'fam', title: t('Décès de {name}', { name: union.partner.firstName }), link: linkTo(union.partner, union.partner.firstName) })
}
}
@@ -189,17 +200,17 @@ export function buildLifeTimeline(person, family = {}, life = null) {
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 }) })
if (within(year)) out.push({ key: `child-${child.id}`, year, order: 5, kind: 'fam', title: t('Naissance de {name}', { name: child.firstName }), link: linkTo(child, 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 }) })
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 }), link: linkTo(child, 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) })
if (within(year) && year !== birthYear) out.push({ key: `sibling-${sibling.id}`, year, order: 5, kind: 'fam', title: siblingWord(sibling), link: linkTo(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 (within(year)) out.push({ key: `parent-death-${parent.id}`, year, order: 6, kind: 'fam', title: parentWord(parent), link: linkTo(parent) })
}
if (deathYear || person.isAlive === false) {
+24 -3
View File
@@ -26,7 +26,7 @@ describe('frise de vie', () => {
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',
'Naissance à Limoges', 'Naissance de son frère André', 'Militaire', 'Mariage avec 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',
])
})
@@ -36,7 +36,7 @@ describe('frise de vie', () => {
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' })
expect(entries.find((e) => e.title === 'Mariage avec 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', () => {
@@ -77,7 +77,7 @@ describe('frise de vie', () => {
],
}, null)
expect(entries.map((e) => e.title)).toEqual([
'Naissance', 'Épouse Marc Roy', 'Naissance de Hugo', 'Naissance de Léa', 'Décès de Marc', 'Épouse Paul Vidal', 'Décès de Paul', 'Épouse Yves Mallet',
'Naissance', 'En couple avec Marc Roy', 'Naissance de Hugo', 'Naissance de Léa', 'Décès de Marc', 'En couple avec Paul Vidal', 'Décès de Paul', 'En couple avec Yves Mallet',
])
// Aucune année inventée : colonne vide, « date inconnue » dit pourquoi
for (const key of ['union-u1', 'union-u2', 'union-u3']) {
@@ -86,6 +86,27 @@ describe('frise de vie', () => {
expect(entries.find((e) => e.key === 'union-u1')).not.toHaveProperty('sortYear')
})
it('couple : « Mariage » seulement quand c’est dit, sinon « En couple avec »', () => {
const lucie = { id: 1, firstName: 'Lucie', birthYear: 1940 }
const marc = { id: 2, firstName: 'Marc', lastName: 'Roy' }
const title = (union, life = null) => buildLifeTimeline(lucie, { unions: [{ id: 'u1', partner: marc, ...union }] }, life).find((e) => e.key === 'union-u1').title
// Le type « mariage » posé par défaut, sans date ni lieu ni document : pas un mariage
expect(title({ unionType: 'mariage' })).toBe('En couple avec Marc Roy')
expect(title({ unionType: 'mariage', startYear: 1962 })).toBe('Mariage avec Marc Roy')
expect(title({ unionType: 'mariage' }, { unions: [{ id: 'u1', startPlace: 'Lille' }] })).toBe('Mariage avec Marc Roy')
expect(title({ unionType: 'mariage' }, { events: [{ id: 'm', type: 'marriage', unionId: 'u1' }] })).toBe('Mariage avec Marc Roy')
// « Pas un mariage » dit explicitement, même daté
expect(title({ unionType: 'union', startYear: 1962 })).toBe('En couple avec Marc Roy')
})
it('les proches sont des liens vers leur fiche', () => {
expect(entries.find((e) => e.key === 'union-u2').link).toEqual({ personId: 5, name: 'Simone Petit' })
expect(entries.find((e) => e.key === 'child-6').link).toEqual({ personId: 6, name: 'Jacques' })
expect(entries.find((e) => e.key === 'sibling-4').link).toEqual({ personId: 4, name: 'André' })
expect(entries.find((e) => e.key === 'parent-death-1').link).toEqual({ personId: 1, name: 'Jean' })
expect(entries.find((e) => e.key === 'birth').link).toBeUndefined()
})
it('un moment sans date dit « date inconnue »', () => {
expect(entries.find((e) => e.title === 'Résidence').meta).toBe('Rue des Écoles · date inconnue')
})