Fantômes : propositions en attente, brouillon et envois de la famille dessinés sur l'arbre ; acceptation ou refus depuis le fantôme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 20:14:58 +01:00
1 parent ddc2231a8a
commit 0c74adb012
15 files changed
+820 -51

No files matched your search

+86
View File
@@ -86,6 +86,9 @@ import './App.css'
import './styles/pz.css'
import { setBooting } from './bootSignal.js'
import { saveContributorName } from './utils/contributorName'
import { ghostsFromChanges, hasGhosts, mergeGhosts } from './utils/ghosts'
import ProposalCard from './components/ProposalCard'
import { summarizeChanges } from './utils/proposalSummary'
function yearToIsoDate(value) {
if (value === null || value === undefined || value === '') {
@@ -688,6 +691,7 @@ function App() {
}, [auth.accountTrees, tree.treeContext.treeId, tree.treeContext.treeName])
const isDemoMode = tree.treeContext.accessMode === 'demo'
const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode
// Recherche de personnes avec scoring
@@ -699,6 +703,56 @@ function App() {
canEdit: canEditInCurrentMode,
})
// Fantômes (1.5.1) : ce qui est proposé et pas encore dans l'arbre, dessiné en transparence à sa place.
// Qui gère l'arbre voit les propositions en attente ; la famille voit son brouillon et ce qu'elle a envoyé.
const ghostTreeId = tree.treeContext.treeId
const ghosts = useMemo(() => {
if (!ghostTreeId || isDemoMode) return null
const graph = { unions }
const list = []
if (userRole.isAdmin) {
for (const session of contrib.contribState.sessions || []) {
if (session.status !== 'pending') continue
list.push(ghostsFromChanges(session.changes, {
source: 'pending', sessionId: session.id, label: session.submittedByLabel, createdAt: session.createdAt,
summary: summarizeChanges(session.changes),
}, graph))
}
} else if (tree.canSubmitContribution) {
for (const proposal of contrib.sentProposals || []) {
list.push(ghostsFromChanges(proposal.changes, {
source: 'sent', sessionId: proposal.sessionId, label: proposal.label, createdAt: proposal.createdAt,
summary: summarizeChanges(proposal.changes),
}, graph))
}
if (editMode.hasDraft) {
const draftChanges = buildContributionChangesFromDraft(editMode.restoreDraft())
list.push(ghostsFromChanges(draftChanges, { source: 'draft', key: 'brouillon', summary: summarizeChanges(draftChanges) }, graph))
}
}
const merged = mergeGhosts(list)
// Photo posée sur l'arbre : l'adresse se refait depuis son chemin, seulement pour une photo de cet arbre
const photoPrefix = `/trees/${ghostTreeId}/annotation-photos/`
merged.annotations = merged.annotations.map((ann) => {
if (ann.type !== 'photo') return ann
try {
const parsed = JSON.parse(ann.content)
const ownPhoto = typeof parsed.photoPath === 'string' && parsed.photoPath.startsWith(photoPrefix)
&& /^[A-Za-z0-9-]+$/.test(parsed.photoPath.slice(photoPrefix.length))
if (!ownPhoto) return null
return { ...ann, content: JSON.stringify({ ...parsed, url: buildMediaUrl(parsed.photoPath, import.meta.env.VITE_API_BASE_URL || '/api') }) }
} catch {
return null
}
}).filter(Boolean)
return hasGhosts(merged) ? merged : null
}, [ghostTreeId, isDemoMode, userRole.isAdmin, tree.canSubmitContribution, contrib.contribState.sessions, contrib.sentProposals, editMode.hasDraft, editMode.draftRevision, tree.graphRevision]) // eslint-disable-line react-hooks/exhaustive-deps
const [selectedGhost, setSelectedGhost] = useState(null)
// Une proposition relue (ou un brouillon envoyé) emporte son fantôme : la carte ouverte se ferme
useEffect(() => {
if (selectedGhost && !ghosts?.persons.some((p) => p.id === selectedGhost.id)) setSelectedGhost(null)
}, [ghosts, selectedGhost])
const localEntityCounterRef = useRef(0)
const makeLocalId = useCallback((prefix) => {
localEntityCounterRef.current += 1
@@ -895,9 +949,18 @@ function App() {
const handlePersonSelect = useCallback(async (personLike) => {
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
if (personId === null || personId === undefined) {
setSelectedGhost(null)
await chooseSelectedPerson(null)
return
}
// Personne proposée : sa carte de proposition, pas une fiche (elle n'existe pas encore dans l'arbre)
if (personLike && typeof personLike === 'object' && personLike.ghost) {
if (!(await leaveSelectedPerson())) return
await chooseSelectedPerson(null)
setSelectedGhost(personLike)
return
}
setSelectedGhost(null)
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
if (!(await leaveSelectedPerson())) return
@@ -1534,6 +1597,16 @@ function App() {
await contrib.handleOpenContributionPanel()
}, [contrib, resolveContributionShareUrl, tree.treeContext.treeId, tree.canEditCurrentTree, tree.treeContext.accessToken, auth.userAuth.token]) // eslint-disable-line react-hooks/exhaustive-deps
// Accepter ou refuser une proposition depuis son fantôme. Une proposition se relit en entier ; « Voir le
// détail » ouvre le panneau pour trier changement par changement.
const reviewFromTree = (sessionId) => ({
busy: contrib.contribState.reviewingSessionId === sessionId,
error: contrib.contribState.error,
onAccept: () => contrib.handleReviewContributionSession(sessionId, { decision: 'approved' }),
onReject: () => contrib.handleReviewContributionSession(sessionId, { decision: 'rejected' }),
onOpenDetails: () => { setSelectedGhost(null); handleOpenContributionPanel() },
})
const handleContributionPasswordRemember = useCallback(async ({ password }) => {
const accessToken = auth.userAuth.token || tree.treeContext.accessToken
if (!tree.treeContext.treeId || !accessToken) {
@@ -3265,8 +3338,17 @@ function App() {
annotationViewRef={annotationViewRef}
onAnnotationsAnchored={persistAnnotationAnchors}
selfPersonId={linkedSelfPersonId || null}
ghosts={ghosts}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{selectedGhost && !tree.selectedPerson && (
<ProposalCard
person={selectedGhost}
summary={selectedGhost.ghost?.summary || ''}
review={userRole.isAdmin && selectedGhost.ghost?.source === 'pending' ? reviewFromTree(selectedGhost.ghost.sessionId) : null}
onClose={() => setSelectedGhost(null)}
/>
)}
{tree.selectedPerson && (
<PersonCard
person={tree.selectedPerson}
@@ -3291,6 +3373,10 @@ function App() {
onRemoveRelation={handleRemoveRelation}
onSetParentage={handleSetParentage}
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
proposal={ghosts?.patches.get(String(tree.selectedPerson.id)) || null}
proposalReview={userRole.isAdmin && ghosts?.patches.get(String(tree.selectedPerson.id))?.ghost?.source === 'pending'
? reviewFromTree(ghosts.patches.get(String(tree.selectedPerson.id)).ghost.sessionId)
: null}
relationActionLoading={relationFeedback.loading}
relationActionSuccess={relationFeedback.success}
relationActionError={relationFeedback.error}
+12
View File
@@ -404,6 +404,18 @@ export async function reviewContributionSession(treeId, sessionId, payload, toke
return data
}
// État d'une proposition envoyée (en attente, acceptée, refusée) : la famille retire ses fantômes une fois relue
export async function fetchContributionStatus(treeId, sessionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/status`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.status || null
}
export async function uploadAnnotationPhoto(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
method: 'POST',
+18 -5
View File
@@ -4,6 +4,7 @@ import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STR
import { easeOutCubic } from './galaxy/utils'
import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf } from './galaxy/blockLayout'
import { hasGhosts, withGhosts } from '../utils/ghosts'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama'
@@ -43,7 +44,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
const centerX = (node) => node.x + node.width / 2
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null }) => {
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null }) => {
const canvasRef = useRef(null)
const layoutDataRef = useRef(null)
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
@@ -56,6 +57,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const activeTabRef = useRef(null)
const familyTabsRef = useRef(familyTabs)
useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs])
// Propositions pas encore relues (utils/ghosts.js) : placées et dessinées en transparence, jamais dans les données
const ghostsRef = useRef(ghosts)
useEffect(() => { ghostsRef.current = ghosts; needsRedrawRef.current = true }, [ghosts])
const onAnnotationsAnchoredRef = useRef(onAnnotationsAnchored)
useEffect(() => { onAnnotationsAnchoredRef.current = onAnnotationsAnchored }, [onAnnotationsAnchored])
const startRefRef = useRef(focusPersonId)
@@ -216,7 +220,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Placement complet pour un ordre de la fratrie, recalé sur la source (les parents ne bougent pas)
const layoutForOrder = useCallback((group, order, base) => {
const tab = activeTabRef.current
const src = tab?.personIds ? subgraphOf(tab.personIds) : { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }
const tabSrc = tab?.personIds ? subgraphOf(tab.personIds) : null
const src = withGhosts(tabSrc || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghostsRef.current, !tabSrc)
const graph = {
persons: src.persons,
unions: src.unions,
@@ -492,7 +497,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Dans l'onglet d'une autre famille, la personne commune (« C'est moi », sinon la personne de départ) reste
// là, reliée par sa ligne directe (Patch, 21/10 : « je veux me voir tout le temps »)
const line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current ?? tabsCacheRef.current.tabs[0]?.resolvedRef, tab.personIds) : new Set()
const graph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
const tabGraph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
const graph = hasGhosts(ghosts)
? withGhosts(tabGraph || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghosts, !tabGraph)
: tabGraph
// Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
const legacy = annotationData.some((a) => !readAnchor(a))
@@ -653,7 +661,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
pendingFocusRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id))
}
}, [graphRevision, activeKey, familyTabs, selfPersonId])
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts])
// Sync selectedPersonId
useEffect(() => {
@@ -929,6 +937,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
entrance, entranceActive, elapsed,
view,
liftScales: liftScalesRef.current,
ghostPatches: ghostsRef.current?.patches || null,
})
drawRenvoiPills(ctx)
// Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus.
@@ -949,7 +958,11 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
if (entranceAlpha > 0) {
drawAnnotations(
ctx,
editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations,
[
...(editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations),
// Annotations proposées : dessinées, jamais touchables (hors de `shown`)
...placeAnnotations(ghostsRef.current?.annotations || [], annotationCenters),
],
annotationHandlers?.selectedAnnotationId ?? null,
annotationHandlers?.drawingPathRef?.current ?? null,
stickerPreview,
+5
View File
@@ -20,6 +20,7 @@ import { isYearOnly } from '../utils/dateInput.js'
import DateField from './DateField.jsx'
import { runOnce } from '../utils/saveOnLeave.js'
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
import { ProposalBanner } from './ProposalCard.jsx'
// Aucune modification en cours (référence stable)
const NO_EDITS = Object.freeze({})
@@ -66,6 +67,9 @@ const PersonCard = ({
onRemoveRelation,
onSetParentage,
pendingParentage = null,
// Modification proposée et pas encore relue (fantôme, 1.5.1) : { fields, deleted, ghost }, et sa relecture
proposal = null,
proposalReview = null,
relationActionLoading = false,
relationActionSuccess = '',
relationActionError = '',
@@ -785,6 +789,7 @@ const PersonCard = ({
</label>
)}
</header>
{!editMode && proposal && <ProposalBanner patch={proposal} review={proposalReview} />}
{avatarActionError && <div className="pz-error pc-inline-msg">{avatarActionError}</div>}
+87
View File
@@ -0,0 +1,87 @@
/* Proposition pas encore relue (1.5.1) : même place et même cadre que la fiche, bordure en pointillés */
.proposal-card {
border-style: dashed;
border-color: var(--pz-a300);
}
.proposal-photo {
background: var(--pz-a50);
outline: 1.5px dashed var(--pz-a500);
outline-offset: 3px;
}
.proposal-eyebrow {
color: var(--pz-a700);
}
.proposal-years {
margin: 2px 0 0;
color: var(--pz-muted);
font-variant-numeric: tabular-nums;
}
.proposal-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.proposal-who,
.proposal-summary,
.proposal-change {
margin: 0;
color: var(--pz-ink-2);
font-size: 14px;
line-height: 1.45;
}
.proposal-summary {
color: var(--pz-muted);
}
.proposal-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.proposal-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.proposal-details {
margin-left: auto;
}
.proposal-error {
margin: 0;
}
/* Fiche existante dont une modification est proposée */
.proposal-banner {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px dashed var(--pz-a300);
border-radius: 12px;
background: var(--pz-a50);
}
.proposal-fields {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
color: var(--pz-ink);
}
.proposal-field {
color: var(--pz-muted);
margin-right: 4px;
}
+126
View File
@@ -0,0 +1,126 @@
import { X } from 'lucide-react'
import { t, dateLocale } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import PzBusy from './PzBusy'
import './ProposalCard.css'
// Proposition pas encore relue, ouverte depuis son fantôme sur l'arbre (1.5.1). Qui gère l'arbre l'accepte ou la
// refuse sur place ; la famille y lit qu'elle attend. Une proposition se relit en entier : « Voir le détail »
// ouvre le panneau des propositions pour trier changement par changement.
const FIELD_LABELS = {
firstName: t('Prénom'),
lastName: t('Nom'),
birthName: t('Nom de naissance'),
nickname: t('Surnom'),
sex: t('Sexe'),
birthDate: t('Naissance'),
deathDate: t('Décès'),
birthPlace: t('Lieu de naissance'),
deathPlace: t('Lieu de décès'),
profession: t('Profession'),
region: t('Région'),
nationality: t('Nationalité'),
notes: t('Note'),
isDeceased: t('Décédé(e)'),
toVerify: t('À vérifier'),
}
function formatValue(key, value) {
if (value === null || value === undefined || value === '') return t('vide')
if (typeof value === 'boolean') return value ? t('oui') : t('non')
if ((key === 'birthDate' || key === 'deathDate') && /^\d{4}-\d{2}-\d{2}/.test(String(value))) return String(value).slice(0, 4)
return String(value)
}
function whoAndWhen(ghost) {
if (ghost?.source === 'draft') return t('Pas encore envoyé : il part avec « Envoyer »')
if (ghost?.source === 'sent') return t('Envoyé, en attente de relecture par la personne qui gère l’arbre')
const date = ghost?.createdAt ? new Date(ghost.createdAt) : null
const when = date && !Number.isNaN(date.getTime()) ? date.toLocaleDateString(dateLocale, { day: 'numeric', month: 'long' }) : ''
if (ghost?.label && when) return t('Proposé par {name}, le {date}', { name: ghost.label, date: when })
if (ghost?.label) return t('Proposé par {name}', { name: ghost.label })
return t('Proposé par la famille')
}
function ReviewActions({ ghost, review }) {
if (!review || ghost?.source !== 'pending') return null
const { busy, error, onAccept, onReject, onOpenDetails } = review
return (
<div className="proposal-actions">
{error && <p className="pz-error proposal-error" role="alert">{error}</p>}
<div className="proposal-buttons">
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={onAccept} disabled={busy}>
<PzBusy busy={busy} busyLabel={t('Relecture en cours')}>{t('Accepter')}</PzBusy>
</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onReject} disabled={busy}>{t('Refuser')}</button>
{onOpenDetails && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm proposal-details" onClick={onOpenDetails} disabled={busy}>{t('Voir le détail')}</button>
)}
</div>
</div>
)
}
/**
* @param {Object} props
* @param {Object} props.person - la personne proposée (fantôme, `person.ghost` porte la provenance)
* @param {string} props.summary - ce que contient la proposition entière (summarizeChanges)
* @param {Object|null} props.review - { busy, error, onAccept, onReject, onOpenDetails } pour qui gère l'arbre
*/
export default function ProposalCard({ person, summary = '', review = null, onClose }) {
const ghost = person?.ghost
const years = [person?.birthYear, person?.deathYear].filter(Boolean).join(' – ')
const name = [firstNameLabel(person), person?.lastName].filter(Boolean).join(' ')
const initials = `${(person?.firstName || '?')[0]}${(person?.lastName || '')[0] || ''}`.toUpperCase()
return (
<div className="person-card proposal-card" role="dialog" aria-label={t('Proposition : {name}', { name })}>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer')}>
<X size={18} strokeWidth={2} />
</button>
<header className="pc-head">
<div className="pc-photo-wrap">
<div className="pc-photo proposal-photo"><span className="pc-photo-initials">{initials}</span></div>
</div>
<div className="pc-id">
<p className="pz-eyebrow proposal-eyebrow">{ghost?.source === 'draft' ? t('Brouillon') : ghost?.source === 'sent' ? t('En attente') : t('Proposition')}</p>
<h2 className="pc-name">
{person?.firstName ? person.firstName : <span className="pc-name-unknown">{firstNameLabel(person)}</span>}
{person?.lastName && <>{' '}<em>{person.lastName}</em></>}
</h2>
{years && <p className="proposal-years">{years}</p>}
</div>
</header>
<div className="proposal-body">
<p className="proposal-who">{whoAndWhen(ghost)}</p>
{summary && <p className="proposal-summary">{t('Dans cette proposition : {summary}.', { summary })}</p>}
<ReviewActions ghost={ghost} review={review} />
</div>
</div>
)
}
/**
* Bandeau d'une fiche existante dont une modification est proposée.
* @param {Object} props.patch - { fields, deleted, ghost }
*/
export function ProposalBanner({ patch, review = null }) {
if (!patch) return null
const entries = Object.entries(patch.fields || {}).filter(([key]) => FIELD_LABELS[key])
return (
<section className="proposal-banner" aria-label={t('Modification proposée')}>
<p className="proposal-who">{whoAndWhen(patch.ghost)}</p>
{patch.deleted ? (
<p className="proposal-change">{t('Supprimer cette personne de l’arbre')}</p>
) : (
<ul className="proposal-fields">
{entries.map(([key, value]) => (
<li key={key}><span className="proposal-field">{FIELD_LABELS[key]}</span> {formatValue(key, value)}</li>
))}
</ul>
)}
<ReviewActions ghost={patch.ghost} review={review} />
</section>
)
}
+3 -1
View File
@@ -397,7 +397,7 @@ export function computeLayout(graphData = null, referenceId = null) {
// Entrées d'union unifiées (réelles + virtuelles)
const allUnionEntries = [
...unions.map(u => ({
elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated),
elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated), ghost: Boolean(u.ghost),
})),
...virtualUnions.map(vu => ({
elkId: vu.elkId, p1Key: `p-${vu.parentId}`, p2Key: vu.unknownElkId, virtual: true,
@@ -592,6 +592,8 @@ export function computeLayout(graphData = null, referenceId = null) {
id: `e-${f.id}`, sources: [sourceId], targets: [targetId],
_parentageType: f.parentageType,
_displayOrder: f.displayOrder,
// Lien proposé, pas encore relu (utils/ghosts.js) : dessiné en pointillés
_ghost: Boolean(f.ghost),
})
})
+85 -11
View File
@@ -100,6 +100,9 @@ function computeCouplePoints(p1Pos, p2Pos, unionPos, p1R, p2R, nodeRandomData, s
// Coins arrondis de rayon fixe (accolade franche, forme A).
// ============================================================
export const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 }
// Proposé, pas encore relu (utils/ghosts.js) : pointillés terracotta, portrait délavé, étiquette au-dessus
export const GHOST = { color: '#D2694A', dash: [5, 4], wash: 'rgba(251, 250, 247, 0.55)', annotationAlpha: 0.45 }
const GHOST_LABELS = { draft: () => t('Brouillon'), sent: () => t('En attente'), pending: () => t('Proposé') }
// Ligne de la fratrie : sous les prénoms et années des parents, au-dessus des enfants
const BUS_DY = 100
// Accolade : coins arrondis de rayon fixe (forme A, choisie par Patch). En dessous de deux rayons d'écart,
@@ -148,7 +151,7 @@ export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) {
// Courbes de couple (Bézier)
// ============================================================
export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) {
coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual }) => {
coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual, ghost }) => {
const p1Pos = posMap.get(p1Key)
const p2Pos = posMap.get(p2Key)
const unionPos = posMap.get(elkId)
@@ -177,9 +180,9 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD
p1PosKeyed, p2PosKeyed, unionPos, p1R, p2R, nodeRandomData, nodeMap.get(elkId)?._compact
)
ctx.strokeStyle = virtual ? COLORS.linkUnknown : FIL.color
ctx.strokeStyle = ghost ? GHOST.color : virtual ? COLORS.linkUnknown : FIL.color
ctx.lineWidth = FIL.width
ctx.setLineDash(virtual ? [4, 4] : [])
ctx.setLineDash(ghost ? GHOST.dash : virtual ? [4, 4] : [])
ctx.lineCap = 'round'
ctx.globalAlpha = coupleProgress
@@ -307,16 +310,21 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr
return
}
// Les enfants d'une même union (même type de filiation) forment une seule fratrie, tracée d'un coup
const key = `${sourceId}|${pType}`
if (!sibships.has(key)) sibships.set(key, { sourceId, sourcePos, pType, items: [], progress: 0 })
// Un enfant proposé a sa propre ligne, en pointillés, par-dessus celle de ses frères et sœurs
const ghost = Boolean(edge._ghost || targetNode?._data?.ghost)
const key = `${sourceId}|${pType}|${ghost ? 'g' : ''}`
if (!sibships.has(key)) sibships.set(key, { sourceId, sourcePos, pType, ghost, items: [], progress: 0 })
const sib = sibships.get(key)
sib.items.push({ cx: targetPos.cx, cy: targetPos.cy - childR })
sib.progress += edgeProgress
})
for (const { sourceId, sourcePos, pType, items, progress } of sibships.values()) {
for (const { sourceId, sourcePos, pType, ghost, items, progress } of sibships.values()) {
// Adoption : trait plein comme une filiation de naissance (le mode d'adoption se dit dans la fiche)
if (pType === 'inconnu') {
if (ghost) {
ctx.setLineDash(GHOST.dash)
ctx.strokeStyle = GHOST.color
} else if (pType === 'inconnu') {
ctx.setLineDash([3, 3])
ctx.strokeStyle = COLORS.linkUnknown
} else {
@@ -1089,6 +1097,8 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela
if (node._type === 'person') {
drawPersonNode(ctx, node, pos, nodeProgress, renderState)
if (node._data?.ghost) drawGhostMark(ctx, node, pos, nodeProgress, view)
else if (renderState.ghostPatches?.has(String(node._data?.id))) drawPatchMark(ctx, node, pos, nodeProgress, renderState.ghostPatches.get(String(node._data.id)))
} else if (node._type === 'union') {
ctx.globalAlpha = nodeProgress
ctx.fillStyle = FIL.dot
@@ -1128,6 +1138,70 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela
})
}
// Personne proposée : portrait délavé, cercle en pointillés, étiquette (« Proposé », « En attente », « Brouillon »)
function drawGhostMark(ctx, node, pos, progress, view) {
const r = nodeRadius(node)
ctx.save()
ctx.globalAlpha = progress
ctx.fillStyle = GHOST.wash
ctx.beginPath()
ctx.arc(pos.cx, pos.cy, r + 1, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = GHOST.color
ctx.lineWidth = 1.6
ctx.setLineDash(GHOST.dash)
ctx.beginPath()
ctx.arc(pos.cx, pos.cy, r + 5, 0, Math.PI * 2)
ctx.stroke()
ctx.setLineDash([])
if ((view?.scale ?? 1) >= 0.35) drawGhostTag(ctx, (GHOST_LABELS[node._data.ghost.source] || GHOST_LABELS.pending)(), pos.cx, pos.cy - r - 14)
ctx.restore()
}
// Personne existante dont une modification est proposée : une pastille en haut à droite du portrait
function drawPatchMark(ctx, node, pos, progress, patch) {
const r = nodeRadius(node)
const angle = -Math.PI / 4
const x = pos.cx + Math.cos(angle) * (r + 2)
const y = pos.cy + Math.sin(angle) * (r + 2)
ctx.save()
ctx.globalAlpha = progress
ctx.fillStyle = '#FBFAF7'
ctx.beginPath()
ctx.arc(x, y, 8, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = GHOST.color
ctx.beginPath()
ctx.arc(x, y, 5.5, 0, Math.PI * 2)
ctx.fill()
if (patch?.deleted) {
ctx.strokeStyle = '#FBFAF7'
ctx.lineWidth = 1.6
ctx.beginPath()
ctx.moveTo(x - 2.6, y)
ctx.lineTo(x + 2.6, y)
ctx.stroke()
}
ctx.restore()
}
function drawGhostTag(ctx, text, cx, cy) {
ctx.font = '500 10px "DM Sans", system-ui, sans-serif'
const w = ctx.measureText(text).width + 12
const h = 16
ctx.fillStyle = '#FBFAF7'
ctx.strokeStyle = GHOST.color
ctx.lineWidth = 1
ctx.beginPath()
ctx.roundRect(cx - w / 2, cy - h / 2, w, h, h / 2)
ctx.fill()
ctx.stroke()
ctx.fillStyle = GHOST.color
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, cx, cy + 0.5)
}
// ============================================================
// Annotations (drawing, sticker, text)
// ============================================================
@@ -1148,7 +1222,7 @@ function drawDrawingAnnotation(ctx, ann) {
ctx.lineWidth = path.width || ann.style?.brushWidth || 3
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.globalAlpha = ann.style?.opacity ?? 1
ctx.globalAlpha *= ann.style?.opacity ?? 1
ctx.beginPath()
ctx.moveTo(path.points[0].x, path.points[0].y)
@@ -1163,7 +1237,7 @@ function drawDrawingAnnotation(ctx, ann) {
function drawStickerAnnotation(ctx, ann) {
const size = ann.style?.fontSize || 40
ctx.save()
ctx.globalAlpha = ann.style?.opacity ?? 1
ctx.globalAlpha *= ann.style?.opacity ?? 1
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
@@ -1177,7 +1251,7 @@ function drawTextAnnotation(ctx, ann) {
const stabilo = ann.style?.stabilo || false
ctx.save()
ctx.globalAlpha = ann.style?.opacity ?? 1
ctx.globalAlpha *= ann.style?.opacity ?? 1
ctx.font = `400 ${fontSize}px "Newsreader", Georgia, serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
@@ -1321,7 +1395,7 @@ export function drawAnnotations(
const lift = isBeingDragged ? dragLiftProgress : 0
ctx.save()
ctx.globalAlpha *= entranceAlpha
ctx.globalAlpha *= entranceAlpha * (ann.ghost ? GHOST.annotationAlpha : 1)
ctx.translate(ann.x, ann.y)
// Lift/fly effect when dragging: scale up + drop shadow, animated via dragLiftProgress
+42 -34
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
fetchContributionStatus,
listContributionSessions,
submitContributionSession,
reviewContributionSession,
batchAnnotations,
} from '../api/treeApi'
import { forgetSentProposal, readSentProposals, rememberSentProposal } from '../utils/sentProposals'
import { getAccountErrorMessage } from '../utils/errorMessages'
import { t } from '../i18n/index.js'
@@ -41,6 +42,34 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
return () => { cancelled = true; document.removeEventListener('visibilitychange', onVisible) }
}, [canEditCurrentTree, treeId, accessToken])
// Ce que la famille a envoyé, gardé dans son navigateur jusqu'à la relecture : dessiné en fantôme « En attente »
// (utils/ghosts.js). Chaque proposition est revérifiée au chargement et au retour sur l'onglet.
const [sentProposals, setSentProposals] = useState([])
const refreshSentProposals = useCallback(async () => {
if (!treeId || !accessToken || canEditCurrentTree) { setSentProposals([]); return }
const stored = readSentProposals(treeId)
setSentProposals(stored)
const stillPending = []
for (const proposal of stored) {
try {
const status = await fetchContributionStatus(treeId, proposal.sessionId, accessToken)
if (status === 'pending') stillPending.push(proposal)
else forgetSentProposal(treeId, proposal.sessionId)
} catch (error) {
// Introuvable : relue puis effacée, ou d'un autre arbre. Hors ligne : on la garde
if (error?.status === 404) forgetSentProposal(treeId, proposal.sessionId)
else stillPending.push(proposal)
}
}
setSentProposals(stillPending)
}, [treeId, accessToken, canEditCurrentTree])
useEffect(() => {
refreshSentProposals()
const onVisible = () => { if (document.visibilityState === 'visible') refreshSentProposals() }
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [refreshSentProposals])
const resetContribState = () => {
setContribState({ ...EMPTY_CONTRIB_STATE })
}
@@ -121,6 +150,14 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
if (result?.session?.status === 'approved') {
await refreshCurrentTreeGraph()
} else if (result?.session?.id && !canEditCurrentTree) {
rememberSentProposal(treeContext.treeId, {
sessionId: result.session.id,
label: payload.submittedByLabel || null,
createdAt: result.session.createdAt || new Date().toISOString(),
changes: payload.changes,
})
setSentProposals(readSentProposals(treeContext.treeId))
}
setContribState((current) => ({
@@ -211,38 +248,7 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
treeContext.accessToken,
)
// Appliquer côté frontend les annotations approuvées (le backend ne les gère pas)
const session = contribState.sessions.find((s) => s.id === sessionId)
if (session) {
const isAllApproved = reviewPayload.decision === 'approved'
const approvedIds = new Set(
(reviewPayload.changeDecisions || [])
.filter((d) => d.decision === 'approved')
.map((d) => d.changeId),
)
const approvedAnnotations = (session.changes || []).filter((c) =>
c.entityType === 'annotation' && (isAllApproved || approvedIds.has(c.id)),
)
if (approvedAnnotations.length > 0) {
const batchPayload = {
create: approvedAnnotations
.filter((c) => c.action === 'create')
.map((c) => c.afterJson || c.after),
update: approvedAnnotations
.filter((c) => c.action === 'update')
.map((c) => ({ id: c.entityId, ...(c.afterJson || c.after) })),
delete: approvedAnnotations
.filter((c) => c.action === 'delete')
.map((c) => c.entityId),
}
try {
await batchAnnotations(treeContext.treeId, batchPayload, treeContext.accessToken)
} catch (annErr) {
console.error('Failed to apply approved annotations:', annErr)
}
}
}
// Les annotations acceptées sont posées par le serveur, dans la même transaction (1.5.1)
await refreshCurrentTreeGraph()
await refreshContributionSessions()
@@ -271,5 +277,7 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
handleSubmitContributionUpdatePerson,
handleSubmitContributionDeletePerson,
handleReviewContributionSession,
sentProposals,
refreshSentProposals,
}
}
+5
View File
@@ -21,6 +21,8 @@ export function useEditMode({ treeId, canEdit }) {
const [isActive, setIsActive] = useState(false)
const [hasDraft, setHasDraft] = useState(false)
const [draftTimestamp, setDraftTimestamp] = useState(null)
// Monte à chaque changement du brouillon : ses fantômes se redessinent (utils/ghosts.js)
const [draftRevision, setDraftRevision] = useState(0)
const autoSaveTimerRef = useRef(null)
const draftDataRef = useRef({})
@@ -151,6 +153,7 @@ export function useEditMode({ treeId, canEdit }) {
}
// Considérer qu'il y a désormais un draft actif (permet le bouton "Mettre à jour l'arbre")
setHasDraft(true)
setDraftRevision((r) => r + 1)
}, [])
/**
@@ -164,6 +167,7 @@ export function useEditMode({ treeId, canEdit }) {
draftDataRef.current = {}
setHasDraft(false)
setDraftTimestamp(null)
setDraftRevision((r) => r + 1)
} catch (err) {
console.error('Failed to clear draft:', err)
}
@@ -180,6 +184,7 @@ export function useEditMode({ treeId, canEdit }) {
// État
isActive,
hasDraft,
draftRevision,
draftTimestamp,
// Actions
+29
View File
@@ -195,4 +195,33 @@ export default {
'Des modifications non enregistrées vous attendent': 'You have unsaved changes waiting',
'Restaurer': 'Restore',
'Ignorer': 'Dismiss',
// ─── Fantômes : propositions pas encore relues, sur l'arbre (1.5.1) ───
'Brouillon': 'Draft',
'En attente': 'Waiting',
'Proposé': 'Suggested',
'Proposition': 'Suggestion',
'oui': 'yes',
'non': 'no',
'Pas encore envoyé : il part avec « Envoyer »': 'Not sent yet: it goes with “Send”',
'Envoyé, en attente de relecture par la personne qui gère l’arbre': 'Sent, waiting for the person who manages the tree to review it',
'Proposé par {name}, le {date}': 'Suggested by {name} on {date}',
'Proposé par {name}': 'Suggested by {name}',
'Proposé par la famille': 'Suggested by the family',
'Relecture en cours': 'Reviewing',
'Voir le détail': 'See details',
'Proposition : {name}': 'Suggestion: {name}',
'Dans cette proposition : {summary}.': 'In this suggestion: {summary}.',
'Modification proposée': 'Suggested change',
'Supprimer cette personne de l’arbre': 'Remove this person from the tree',
'{n} personne': '{n} person',
'{n} personnes': '{n} people',
'{n} fiche modifiée': '{n} profile edited',
'{n} fiches modifiées': '{n} profiles edited',
'{n} lien': '{n} link',
'{n} liens': '{n} links',
"{n} ajout sur l'arbre": '{n} addition on the tree',
"{n} ajouts sur l'arbre": '{n} additions on the tree',
'{n} autre changement': '{n} other change',
'{n} autres changements': '{n} other changes',
}
+184
View File
@@ -0,0 +1,184 @@
// Fantômes (1.5.1) : ce qui est proposé mais pas encore dans l'arbre, dessiné en transparence à sa place.
// Trois sources, même format de changements que l'envoi d'une proposition :
// - « draft » : le brouillon de la famille, pas encore envoyé ;
// - « sent » : ce que la famille a envoyé, gardé dans son navigateur jusqu'à la relecture ;
// - « pending » : les propositions en attente, vues par qui gère l'arbre.
// Les fantômes ne touchent jamais aux données de l'arbre : ils ne s'ajoutent qu'au placement et au dessin.
function afterOf(change) {
const after = change?.afterJson ?? change?.after
return after && typeof after === 'object' && !Array.isArray(after) ? after : null
}
function yearOf(isoDate) {
const match = typeof isoDate === 'string' ? isoDate.match(/^(\d{4})/) : null
return match ? Number(match[1]) : null
}
function unionIdFor(parentId, unions) {
const own = unions.filter((u) => String(u.partner1Id) === String(parentId) || String(u.partner2Id) === String(parentId))
return own.length === 1 ? String(own[0].id) : null
}
/**
* Fantômes d'une liste de changements.
* @param {Array} changes - changements d'une proposition ({ entityType, action, entityId, after | afterJson, id })
* @param {Object} meta - { source, key, sessionId?, label?, createdAt? } ; `key` rend les identifiants uniques
* @param {Object} graph - { unions } de l'arbre, pour rattacher un enfant proposé au couple de son parent
*/
export function ghostsFromChanges(changes = [], meta = {}, graph = {}) {
const key = String(meta.key || meta.sessionId || 'brouillon')
const ghost = { ...meta, key }
const refs = new Map()
const persons = []
const unions = []
const filiations = []
const annotations = []
const patches = new Map()
const resolve = (id) => (id == null ? null : refs.get(String(id)) || String(id))
changes.forEach((change, index) => {
if (change?.entityType !== 'person' || change.action !== 'create') return
const after = afterOf(change)
if (!after) return
const id = `ghost:${key}:${after.ref || change.id || index}`
if (after.ref) refs.set(String(after.ref), id)
persons.push({
id,
firstName: after.firstName || '',
lastName: after.lastName || '',
birthName: after.birthName || null,
nickname: after.nickname || null,
sex: after.sex || null,
birthDate: after.birthDate || null,
deathDate: after.deathDate || null,
birthYear: yearOf(after.birthDate),
deathYear: yearOf(after.deathDate),
isAlive: !after.deathDate && !after.isDeceased,
photo: null,
frameType: 'round',
ghost,
})
})
changes.forEach((change, index) => {
if (change?.entityType !== 'union' || change.action !== 'create') return
const after = afterOf(change)
if (!after?.partner1PersonId || !after?.partner2PersonId) return
unions.push({
id: `ghost:${key}:u${change.id || index}`,
partner1Id: resolve(after.partner1PersonId),
partner2Id: resolve(after.partner2PersonId),
unionType: after.unionType || 'mariage',
displayOrder: 9999,
ghost,
})
})
const allUnions = [...(graph.unions || []), ...unions]
changes.forEach((change, index) => {
const after = afterOf(change)
if (!after) return
if (change.entityType === 'parent_child_link' && change.action === 'create') {
if (!after.parentPersonId || !after.childPersonId) return
const parentId = resolve(after.parentPersonId)
const unionId = after.viaUnionId ? String(after.viaUnionId) : unionIdFor(parentId, allUnions)
filiations.push({
id: `ghost:${key}:f${change.id || index}`,
childId: resolve(after.childPersonId),
parentId: unionId ? null : parentId,
unionId,
parentageType: after.parentageType || 'biologique',
displayOrder: 9999,
ghost,
})
} else if (change.entityType === 'person' && (change.action === 'update' || change.action === 'delete') && change.entityId) {
const previous = patches.get(String(change.entityId))
patches.set(String(change.entityId), {
...previous,
fields: { ...(previous?.fields || {}), ...(change.action === 'update' ? after : {}) },
deleted: change.action === 'delete' || Boolean(previous?.deleted),
ghost,
})
} else if (change.entityType === 'annotation' && change.action === 'create' && meta.source !== 'draft') {
// Le brouillon dessine déjà ses annotations : seules celles envoyées ou en attente deviennent fantômes
annotations.push({
id: `ghost:${key}:a${change.id || index}`,
type: after.type,
x: Number(after.x) || 0,
y: Number(after.y) || 0,
content: after.content || '',
style: after.style || {},
zIndex: after.zIndex || 0,
ghost,
})
}
})
return { persons, unions, filiations, annotations, patches }
}
export function emptyGhosts() {
return { persons: [], unions: [], filiations: [], annotations: [], patches: new Map() }
}
export function mergeGhosts(list = []) {
const merged = emptyGhosts()
for (const g of list) {
if (!g) continue
merged.persons.push(...g.persons)
merged.unions.push(...g.unions)
merged.filiations.push(...g.filiations)
merged.annotations.push(...g.annotations)
for (const [id, patch] of g.patches) if (!merged.patches.has(id)) merged.patches.set(id, patch)
}
return merged
}
export function hasGhosts(g) {
return Boolean(g && (g.persons.length || g.unions.length || g.filiations.length || g.annotations.length || g.patches.size))
}
/**
* Le graphe à placer, fantômes compris. Dans un onglet de famille, seuls les fantômes reliés (de proche en
* proche) à une personne de l'onglet y entrent ; l'arbre entier les prend tous.
* @param {{persons, unions, filiations}} base
* @param {ReturnType<typeof ghostsFromChanges>} ghosts
* @param {boolean} whole - base = l'arbre entier
*/
export function withGhosts(base, ghosts, whole = false) {
if (!hasGhosts(ghosts)) return base
const keep = new Set(base.persons.map((p) => String(p.id)))
const ghostById = new Map(ghosts.persons.map((p) => [String(p.id), p]))
const allUnions = [...base.unions, ...ghosts.unions]
const unionById = new Map(allUnions.map((u) => [String(u.id), u]))
const parentsOf = (f) => (f.unionId ? [unionById.get(String(f.unionId))?.partner1Id, unionById.get(String(f.unionId))?.partner2Id] : [f.parentId]).filter((id) => id != null).map(String)
if (whole) {
for (const id of ghostById.keys()) keep.add(id)
} else {
let grew = true
while (grew) {
grew = false
const touch = (a, b) => {
if (keep.has(a) && ghostById.has(b) && !keep.has(b)) { keep.add(b); grew = true }
if (keep.has(b) && ghostById.has(a) && !keep.has(a)) { keep.add(a); grew = true }
}
for (const u of ghosts.unions) touch(String(u.partner1Id), String(u.partner2Id))
for (const f of ghosts.filiations) for (const p of parentsOf(f)) touch(p, String(f.childId))
}
}
const persons = [...base.persons, ...[...ghostById.values()].filter((p) => keep.has(String(p.id)))]
const unions = [...base.unions, ...ghosts.unions.filter((u) => keep.has(String(u.partner1Id)) && keep.has(String(u.partner2Id)))]
const unionIds = new Set(unions.map((u) => String(u.id)))
const filiations = [
...base.filiations,
...ghosts.filiations.filter((f) => keep.has(String(f.childId)) && (f.unionId ? unionIds.has(String(f.unionId)) : keep.has(String(f.parentId)))),
]
return { persons, unions, filiations }
}
export function isGhostId(id) {
return typeof id === 'string' && (id.startsWith('ghost:') || id.startsWith('p-ghost:'))
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { ghostsFromChanges, hasGhosts, isGhostId, mergeGhosts, withGhosts } from './ghosts'
const base = {
persons: [{ id: 'jean' }, { id: 'marie' }, { id: 'paul' }],
unions: [{ id: 'u1', partner1Id: 'jean', partner2Id: 'marie' }],
filiations: [{ id: 'f1', childId: 'paul', unionId: 'u1', parentId: null }],
}
describe('fantômes des propositions', () => {
it("un enfant proposé à Jean se rattache au couple Jean et Marie, pas à un parent inconnu", () => {
const g = ghostsFromChanges([
{ id: 'c1', entityType: 'person', action: 'create', afterJson: { ref: 'tmp:a', firstName: 'Lucie', lastName: 'Martin', birthDate: '1990-03-01' } },
{ id: 'c2', entityType: 'parent_child_link', action: 'create', afterJson: { parentPersonId: 'jean', childPersonId: 'tmp:a' } },
], { source: 'pending', sessionId: 's1', label: 'Cousine' }, base)
expect(g.persons).toHaveLength(1)
expect(g.persons[0]).toMatchObject({ firstName: 'Lucie', birthYear: 1990, ghost: { source: 'pending', sessionId: 's1' } })
expect(g.filiations[0]).toMatchObject({ childId: g.persons[0].id, unionId: 'u1', parentId: null })
expect(isGhostId(g.persons[0].id)).toBe(true)
})
it('les changements sur une personne existante deviennent une marque, pas un double', () => {
const g = ghostsFromChanges([
{ entityType: 'person', action: 'update', entityId: 'paul', after: { birthPlace: 'Lille' } },
{ entityType: 'person', action: 'update', entityId: 'paul', after: { profession: 'Facteur' } },
], { source: 'sent', key: 'k' }, base)
expect(g.persons).toHaveLength(0)
expect(g.patches.get('paul').fields).toEqual({ birthPlace: 'Lille', profession: 'Facteur' })
})
it("le brouillon ne double pas ses annotations, déjà dessinées ; une proposition envoyée oui", () => {
const change = { entityType: 'annotation', action: 'create', after: { type: 'text', x: 1, y: 2, content: 'Mamie' } }
expect(ghostsFromChanges([change], { source: 'draft' }).annotations).toHaveLength(0)
expect(ghostsFromChanges([change], { source: 'sent', key: 's' }).annotations).toHaveLength(1)
})
it("dans un onglet, seuls les fantômes reliés de proche en proche à une personne de l'onglet entrent", () => {
const g = ghostsFromChanges([
{ entityType: 'person', action: 'create', after: { ref: 'tmp:a', firstName: 'Enfant' } },
{ entityType: 'person', action: 'create', after: { ref: 'tmp:b', firstName: 'Petit-enfant' } },
{ entityType: 'person', action: 'create', after: { ref: 'tmp:c', firstName: 'Ailleurs' } },
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'paul', childPersonId: 'tmp:a' } },
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'tmp:a', childPersonId: 'tmp:b' } },
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'inconnu-hors-onglet', childPersonId: 'tmp:c' } },
], { source: 'pending', sessionId: 's2' }, base)
const tab = withGhosts(base, g)
expect(tab.persons.map((p) => p.firstName).filter(Boolean)).toEqual(['Enfant', 'Petit-enfant'])
expect(tab.filiations).toHaveLength(3)
expect(withGhosts(base, g, true).persons).toHaveLength(6)
})
it("deux propositions qui utilisent la même référence provisoire ne se mélangent pas", () => {
const one = ghostsFromChanges([{ entityType: 'person', action: 'create', after: { ref: 'tmp:x', firstName: 'A' } }], { source: 'pending', sessionId: 's1' })
const two = ghostsFromChanges([{ entityType: 'person', action: 'create', after: { ref: 'tmp:x', firstName: 'B' } }], { source: 'pending', sessionId: 's2' })
const merged = mergeGhosts([one, two])
expect(new Set(merged.persons.map((p) => p.id)).size).toBe(2)
expect(hasGhosts(merged)).toBe(true)
expect(withGhosts(base, mergeGhosts([]))).toBe(base)
})
})
+24
View File
@@ -0,0 +1,24 @@
import { t } from '../i18n/index.js'
function plural(count, one, many) {
return count > 1 ? t(many, { n: count }) : t(one, { n: count })
}
// Ce que contient une proposition, en une ligne : « 1 personne, 2 liens, 1 photo sur l'arbre »
export function summarizeChanges(changes = []) {
const count = { person: 0, update: 0, link: 0, annotation: 0, other: 0 }
for (const change of changes) {
if (change.entityType === 'person' && change.action === 'create') count.person += 1
else if (change.entityType === 'person') count.update += 1
else if (change.entityType === 'union' || change.entityType === 'parent_child_link') count.link += 1
else if (change.entityType === 'annotation') count.annotation += 1
else count.other += 1
}
return [
count.person && plural(count.person, '{n} personne', '{n} personnes'),
count.update && plural(count.update, '{n} fiche modifiée', '{n} fiches modifiées'),
count.link && plural(count.link, '{n} lien', '{n} liens'),
count.annotation && plural(count.annotation, "{n} ajout sur l'arbre", "{n} ajouts sur l'arbre"),
count.other && plural(count.other, '{n} autre changement', '{n} autres changements'),
].filter(Boolean).join(', ')
}
+53
View File
@@ -0,0 +1,53 @@
// Propositions envoyées par la famille depuis ce navigateur, gardées jusqu'à leur relecture pour les dessiner
// en fantôme « En attente ». Ce sont ses propres changements : rien d'autre n'est gardé. Le stockage peut manquer
// (navigation privée, quota) : on dessine alors simplement sans fantômes.
const MAX_KEPT = 30
// Au-delà, une proposition jamais relue n'est plus dessinée (le relecteur l'a peut-être oubliée, pas la famille)
const MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000
const keyOf = (treeId) => `parenthese-propositions-${treeId}`
export function readSentProposals(treeId, now = Date.now()) {
if (!treeId) return []
try {
const parsed = JSON.parse(localStorage.getItem(keyOf(treeId)) || '[]')
if (!Array.isArray(parsed)) return []
return parsed.filter((p) => p && typeof p.sessionId === 'string' && Array.isArray(p.changes)
&& now - (Date.parse(p.createdAt) || now) < MAX_AGE_MS)
} catch {
return []
}
}
function write(treeId, list) {
try {
if (list.length) localStorage.setItem(keyOf(treeId), JSON.stringify(list.slice(-MAX_KEPT)))
else localStorage.removeItem(keyOf(treeId))
} catch {
// Stockage indisponible : pas de fantômes « En attente », rien d'autre ne change
}
}
// Une photo posée sur l'arbre garde dans son contenu l'adresse affichée, avec le jeton médias : seul le chemin
// est gardé, l'adresse est refaite au dessin
function withoutPhotoUrl(change) {
if (change?.entityType !== 'annotation' || typeof change.after?.content !== 'string' || !change.after.content.startsWith('{')) return change
try {
const { url: _url, ...rest } = JSON.parse(change.after.content)
return { ...change, after: { ...change.after, content: JSON.stringify(rest) } }
} catch {
return change
}
}
export function rememberSentProposal(treeId, proposal) {
if (!treeId || !proposal?.sessionId) return
const kept = { ...proposal, changes: (proposal.changes || []).map(withoutPhotoUrl) }
write(treeId, [...readSentProposals(treeId).filter((p) => p.sessionId !== proposal.sessionId), kept])
}
export function forgetSentProposal(treeId, sessionId) {
if (!treeId) return
write(treeId, readSentProposals(treeId).filter((p) => p.sessionId !== sessionId))
}