Files
nur-falah-prevention/src/lib/FamilyTree.svelte
T
wmj e7a111c387 Add mini family genealogy tree with per-person photos
Per explicit request: "mini family genealogy mapping with ability to add
photos on each family member." Scoped via clarifying questions: people are
lightweight records independent of login accounts (most real tree nodes —
grandparents, deceased relatives, young children — never sign up),
relationships support parent/child + spouse (enough to render a real
multi-generation tree), photos go to Supabase Storage (native to the
backend already in use, no new infrastructure).

Schema: nf_people (name, gender, birth/death dates, notes, optional
linked_user_id if the person also happens to have a real account,
photo_path) and nf_relationships (person_a/person_b + type: parent_of
directional, spouse_of symmetric). Both family-member-shared like the
Asset Registry — any owner/agent/member can view and edit, unlike the
per-author Wassiyah/Waqf model. New private Storage bucket
nf-people-photos, path convention {family_id}/{person_id}/{filename} so
RLS can check family membership straight from the path without a join.

New FamilyTree.svelte: add/edit/delete a person, upload/replace/remove
their photo (signed URLs, 1hr TTL, refreshed on every load since the
bucket is private), link/unlink relationships, and a recursive generational
tree render — roots are anyone with no recorded parent, spouses shown
inline next to their partner rather than as separate branches.

e2e-family-tree.cjs: 4-person 3-generation tree built end to end against
the live backend — add people, link parent/child + spouse relationships,
upload a real PNG to Supabase Storage and confirm it renders as the
avatar, remove it and confirm it reverts to the initial-letter fallback,
edit a person's notes, delete a relationship, and confirm a second family
under the same account sees none of this tree (isolation, same pattern
already proven for Wassiyah/Waqf). Found two real test-authoring bugs
along the way (not app bugs): a case-insensitive :has-text substring match
on "Spouse" was also matching the relationship-type select's own "is
spouse of" option text, and a person-summary click meant to inspect an
already-expanded card was instead toggling it closed. Both fixed by
selecting on DOM position/structure instead of loose text matching.
9/9 passing.

Full regression sweep: e2e-uat 32/32, e2e-fastpath 16/16, e2e-trust 12/12,
e2e-business 10/10, e2e-digital-vehicle 10/10 (one transient failure on
first run, passed clean on retry — consistent with earlier-observed
flakiness under heavy parallel test load, not a regression),
e2e-property 9/9, e2e-other 4/4, e2e-info 31/31, e2e-per-member 11/11,
e2e-family-tree 9/9 — 174/174 total.
2026-08-14 09:16:42 +08:00

338 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// Mini family genealogy map. People here are lightweight records (nf_people)
// independent of login accounts — most real tree nodes (grandparents,
// deceased relatives, young children) never sign up. Relationships are
// parent_of (directional) and spouse_of (symmetric, queried both ways).
import { onMount } from 'svelte';
import { activeFamilyId } from './family.js';
import { currentUser } from './auth.js';
import {
listPeople, addPerson, updatePerson, removePerson,
uploadPersonPhoto, removePersonPhoto, getPersonPhotoUrl,
listRelationships, addRelationship, removeRelationship
} from './db.js';
import InfoPanel from './InfoPanel.svelte';
import Disclaimer from './Disclaimer.svelte';
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
let people = $state([]);
let relationships = $state([]);
let photoUrls = $state({}); // personId -> signed url
let error = $state('');
let form = $state({ fullName: '', gender: '', birthDate: '', deathDate: '', notes: '' });
let editingId = $state(null);
let relForm = $state({ personAId: '', personBId: '', type: 'parent_of' });
let uploadingFor = $state(null);
let expandedId = $state(null);
async function refresh() {
if (!familyId) return;
people = await listPeople(familyId);
relationships = await listRelationships(familyId);
// Refresh signed URLs for anyone with a photo — private bucket, so these expire.
const entries = await Promise.all(
people.filter(p => p.photoPath).map(async p => [p.id, await getPersonPhotoUrl(p.photoPath)])
);
photoUrls = Object.fromEntries(entries);
}
onMount(refresh);
$effect(() => { familyId; refresh(); });
function emptyForm() {
return { fullName: '', gender: '', birthDate: '', deathDate: '', notes: '' };
}
async function savePerson() {
error = '';
if (!form.fullName) return;
try {
if (editingId) {
await updatePerson(editingId, form);
editingId = null;
} else {
await addPerson(familyId, currentUser()?.id, form);
}
form = emptyForm();
await refresh();
} catch (e) { error = e.message; }
}
function startEdit(p) {
editingId = p.id;
form = { fullName: p.fullName, gender: p.gender || '', birthDate: p.birthDate || '', deathDate: p.deathDate || '', notes: p.notes || '' };
}
function cancelEdit() {
editingId = null;
form = emptyForm();
}
async function deletePerson(id) {
if (!confirm('Remove this person and all their recorded relationships? This cannot be undone.')) return;
await removePerson(id);
await refresh();
}
async function handlePhotoChange(e, personId) {
const file = e.target.files?.[0];
if (!file) return;
uploadingFor = personId;
error = '';
try {
await uploadPersonPhoto(familyId, personId, file);
await refresh();
} catch (err) {
error = 'Photo upload failed: ' + err.message;
} finally {
uploadingFor = null;
e.target.value = '';
}
}
async function handlePhotoRemove(person) {
await removePersonPhoto(person.id, person.photoPath);
await refresh();
}
async function addRel() {
error = '';
if (!relForm.personAId || !relForm.personBId || relForm.personAId === relForm.personBId) {
error = 'Pick two different people.';
return;
}
try {
await addRelationship(familyId, relForm.personAId, relForm.personBId, relForm.type);
relForm = { personAId: '', personBId: '', type: 'parent_of' };
await refresh();
} catch (e) { error = e.message; }
}
async function delRel(id) {
await removeRelationship(id);
await refresh();
}
function personName(id) {
return people.find(p => p.id === id)?.fullName || '(removed)';
}
// Build a simple generational tree: roots = people with no recorded parent.
// Each node's children = people this person is parent_of. Spouses shown
// alongside their partner rather than as separate tree branches.
const childrenOf = $derived.by(() => {
const map = {};
for (const r of relationships.filter(r => r.type === 'parent_of')) {
(map[r.personAId] ??= []).push(r.personBId);
}
return map;
});
const spousesOf = $derived.by(() => {
const map = {};
for (const r of relationships.filter(r => r.type === 'spouse_of')) {
(map[r.personAId] ??= []).push(r.personBId);
(map[r.personBId] ??= []).push(r.personAId);
}
return map;
});
const hasParent = $derived(new Set(relationships.filter(r => r.type === 'parent_of').map(r => r.personBId)));
const roots = $derived(people.filter(p => !hasParent.has(p.id)));
function toggleExpand(id) {
expandedId = expandedId === id ? null : id;
}
</script>
<div class="module">
<div class="module-header">
<h2>Family Tree</h2>
<InfoPanel
title="Family Tree"
what="A simple map of your family — parents, children, spouses, and a photo for each person. Includes anyone: grandparents, relatives who've passed away, children too young for their own account. Nobody here needs to sign in."
how="Add a person, then link them to others as a parent/child or spouse using the relationship form. The tree below groups people by generation automatically, starting from anyone with no parent recorded."
fields={[
{ label: 'Full name', hint: 'The only required field.' },
{ label: 'Birth / death date', hint: 'Optional — leave blank if unknown.' },
{ label: 'Photo', hint: 'Upload after adding the person — tap their card to expand it.' },
{ label: 'Relationships', hint: 'Parent of (directional) or Spouse of (mutual) — pick two people and a type.' }
]}
/>
</div>
<p class="sub">Shared family knowledge — any family member can view and edit this map.</p>
{#if error}<p class="error-text">{error}</p>{/if}
<div class="form-card">
<h3>{editingId ? 'Edit person' : 'Add a person'}</h3>
<label class="field"><span>Full name</span><input type="text" bind:value={form.fullName} /></label>
<label class="field"><span>Gender</span>
<select bind:value={form.gender}>
<option value="">Unspecified</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</label>
<label class="field"><span>Birth date</span><input type="date" bind:value={form.birthDate} /></label>
<label class="field"><span>Death date (if applicable)</span><input type="date" bind:value={form.deathDate} /></label>
<label class="field"><span>Notes</span><input type="text" bind:value={form.notes} placeholder="e.g. maiden name, place of origin" /></label>
<div class="btn-row">
<button class="btn-primary" onclick={savePerson}>{editingId ? 'Save changes' : 'Add person'}</button>
{#if editingId}<button class="btn-secondary" onclick={cancelEdit}>Cancel</button>{/if}
</div>
</div>
{#if people.length >= 2}
<div class="form-card">
<h3>Add a relationship</h3>
<label class="field"><span>Person</span>
<select bind:value={relForm.personAId}>
<option value="">Select…</option>
{#each people as p}<option value={p.id}>{p.fullName}</option>{/each}
</select>
</label>
<label class="field"><span>Relationship</span>
<select bind:value={relForm.type}>
<option value="parent_of">is parent of</option>
<option value="spouse_of">is spouse of</option>
</select>
</label>
<label class="field"><span>{relForm.type === 'parent_of' ? 'Child' : 'Spouse'}</span>
<select bind:value={relForm.personBId}>
<option value="">Select…</option>
{#each people as p}<option value={p.id}>{p.fullName}</option>{/each}
</select>
</label>
<button class="btn-primary" onclick={addRel}>Add relationship</button>
</div>
{/if}
<div class="tree-section">
<h3>Tree</h3>
{#if roots.length === 0}
<p class="empty">Add people and relationships above to build the tree.</p>
{/if}
{#each roots as root (root.id)}
{@render personNode(root, 0)}
{/each}
</div>
<div class="all-people-section">
<h3>All people</h3>
{#each people as p (p.id)}
<div class="person-card">
<button class="person-summary" onclick={() => toggleExpand(p.id)}>
{#if photoUrls[p.id]}
<img class="avatar" src={photoUrls[p.id]} alt={p.fullName} />
{:else}
<div class="avatar avatar-placeholder">{p.fullName.charAt(0).toUpperCase()}</div>
{/if}
<div class="person-info">
<strong>{p.fullName}</strong>
<span class="muted">{[p.birthDate, p.deathDate].filter(Boolean).join(' – ') || 'dates unknown'}</span>
</div>
</button>
{#if expandedId === p.id}
<div class="person-detail">
{#if p.notes}<p class="notes">{p.notes}</p>{/if}
<div class="photo-controls">
<label class="upload-btn">
{uploadingFor === p.id ? 'Uploading…' : (photoUrls[p.id] ? 'Replace photo' : 'Upload photo')}
<input type="file" accept="image/*" onchange={(e) => handlePhotoChange(e, p.id)} disabled={uploadingFor === p.id} />
</label>
{#if photoUrls[p.id]}<button class="btn-small-danger" onclick={() => handlePhotoRemove(p)}>Remove photo</button>{/if}
</div>
<div class="btn-row">
<button class="btn-small" onclick={() => startEdit(p)}>Edit</button>
<button class="btn-small-danger" onclick={() => deletePerson(p.id)}>Delete</button>
</div>
</div>
{/if}
</div>
{:else}
<p class="empty">No one added yet.</p>
{/each}
</div>
<div class="relationships-section">
<h3>Relationships</h3>
{#each relationships as r (r.id)}
<div class="rel-row">
<span>{personName(r.personAId)} {r.type === 'parent_of' ? 'is parent of' : 'is spouse of'} {personName(r.personBId)}</span>
<button onclick={() => delRel(r.id)}>✕</button>
</div>
{:else}
<p class="empty">No relationships recorded yet.</p>
{/each}
</div>
<Disclaimer text="This tree is informational family knowledge — it is not itself a Faraid heir determination. Use the Faraid Calculator for that." />
</div>
{#snippet personNode(person, depth)}
<div class="tree-node" style="margin-left: {depth * 20}px">
<div class="tree-person">
{#if photoUrls[person.id]}
<img class="avatar avatar-sm" src={photoUrls[person.id]} alt={person.fullName} />
{:else}
<div class="avatar avatar-sm avatar-placeholder">{person.fullName.charAt(0).toUpperCase()}</div>
{/if}
<span>{person.fullName}</span>
{#each (spousesOf[person.id] || []) as spouseId}
<span class="spouse-tag">⚭ {personName(spouseId)}</span>
{/each}
</div>
{#each (childrenOf[person.id] || []) as childId}
{@const child = people.find(p => p.id === childId)}
{#if child}
{@render personNode(child, depth + 1)}
{/if}
{/each}
</div>
{/snippet}
<style>
.module { padding: 4px 0 40px; }
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 0; }
h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
.error-text { color: #EF4444; font-size: 12.5px; margin-bottom: 12px; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.field input, .field select { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; width: 100%; }
.btn-row { display: flex; gap: 8px; }
.btn-primary { flex: 1; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-secondary { padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; cursor: pointer; }
.btn-small { background: rgba(255,255,255,0.08); color: #E8E4DC; border: none; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; }
.btn-small-danger { background: rgba(239,68,68,0.1); color: #EF4444; border: none; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; }
.tree-section, .all-people-section, .relationships-section { margin-bottom: 20px; }
.tree-node { padding: 6px 0; border-left: 1px solid rgba(201,168,76,0.15); padding-left: 10px; }
.tree-person { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #E8E4DC; }
.spouse-tag { font-size: 11px; color: #8A8478; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 16px 0; }
.person-card { background: rgba(255,255,255,0.03); border-radius: 12px; margin-bottom: 8px; overflow: hidden; }
.person-summary { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px; background: none; border: none; cursor: pointer; text-align: left; }
.avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.avatar-sm { width: 24px; height: 24px; }
.avatar-placeholder { display: flex; align-items: center; justify-content: center; background: rgba(201,168,76,0.15); color: #C9A84C; font-weight: 700; font-size: 15px; }
.person-info { display: flex; flex-direction: column; gap: 2px; }
.person-info strong { color: #E8E4DC; font-size: 13.5px; }
.muted { color: #8A8478; font-size: 11px; }
.person-detail { padding: 0 12px 12px; }
.notes { font-size: 12px; color: #B8B2A6; margin-bottom: 10px; }
.photo-controls { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
.upload-btn { position: relative; background: rgba(201,168,76,0.15); color: #C9A84C; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; font-weight: 600; }
.upload-btn input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.rel-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 12.5px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
.rel-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
</style>