From e7a111c387c1b14847850236db0672b019b7ee2c Mon Sep 17 00:00:00 2001 From: wmj Date: Fri, 14 Aug 2026 09:16:42 +0800 Subject: [PATCH] Add mini family genealogy tree with per-person photos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e-family-tree.cjs | 127 ++++++++++++++ src/App.svelte | 12 +- src/lib/FamilyTree.svelte | 337 ++++++++++++++++++++++++++++++++++++++ src/lib/db.js | 77 +++++++++ test-photo.png | Bin 0 -> 68 bytes 5 files changed, 548 insertions(+), 5 deletions(-) create mode 100644 e2e-family-tree.cjs create mode 100644 src/lib/FamilyTree.svelte create mode 100644 test-photo.png diff --git a/e2e-family-tree.cjs b/e2e-family-tree.cjs new file mode 100644 index 0000000..4340e9f --- /dev/null +++ b/e2e-family-tree.cjs @@ -0,0 +1,127 @@ +// Verifies the mini family genealogy map end-to-end against the live backend: +// add people (with photo upload to Supabase Storage), link parent/child and +// spouse relationships, confirm the generational tree renders correctly, and +// that a second family's tree is fully isolated (no data bleed across +// families sharing the same account, matching the isolation pattern already +// proven for Wassiyah/Waqf). +const { chromium } = require('playwright'); +const path = require('path'); +const { signInFreshFamily } = require('./e2e-auth-helper.cjs'); +const BASE = 'https://moslem04.falahos.my/'; +const results = []; +const consoleErrors = []; +function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); } + +async function main() { + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); + page.on('pageerror', e => consoleErrors.push(e.message)); + + const familyName = await signInFreshFamily(page, BASE, 'e2e-family-tree'); + await page.locator('nav button[aria-label="Tree"]').click(); + await page.waitForTimeout(600); + + // Add three people: grandfather, father, son + async function addPerson(name, extra = {}) { + await page.locator('.form-card .field:has-text("Full name") input').fill(name); + if (extra.gender) await page.locator('.form-card .field:has-text("Gender") select').selectOption(extra.gender); + if (extra.birthDate) await page.locator('.form-card .field:has-text("Birth date") input').fill(extra.birthDate); + await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click(); + await page.locator('.person-card', { hasText: name }).waitFor({ state: 'visible', timeout: 10000 }); + } + + await addPerson('Grandfather Ahmad', { gender: 'male', birthDate: '1940-01-01' }); + await addPerson('Father Ismail', { gender: 'male', birthDate: '1965-05-15' }); + await addPerson('Grandmother Fatimah', { gender: 'female', birthDate: '1945-03-10' }); + await addPerson('Son Yusuf', { gender: 'male', birthDate: '1995-08-20' }); + + const allAdded = await page.locator('.person-card').count(); + record('Family Tree: 4 people added successfully', allAdded === 4, `${allAdded} cards`); + + // Link relationships: Grandfather is parent of Father; Grandfather spouse_of Grandmother; Father parent of Son + async function addRelationship(personALabel, type, personBLabel) { + // The relationship form-card has exactly 3 selects in DOM order: person A, + // type, person B. Selecting by position avoids :has-text substring + // ambiguity (the type select's own option "is spouse of" contains + // "spouse", so a text-based locator for the third field also matches it). + const relCard = page.locator('.form-card', { has: page.locator('button.btn-primary', { hasText: 'Add relationship' }) }); + const selects = relCard.locator('select'); + await selects.nth(0).selectOption({ label: personALabel }); + await selects.nth(1).selectOption(type); + await page.waitForTimeout(200); + await selects.nth(2).selectOption({ label: personBLabel }); + await page.locator('button.btn-primary', { hasText: 'Add relationship' }).click(); + await page.waitForTimeout(600); + } + + await addRelationship('Grandfather Ahmad', 'parent_of', 'Father Ismail'); + await addRelationship('Grandfather Ahmad', 'spouse_of', 'Grandmother Fatimah'); + await addRelationship('Father Ismail', 'parent_of', 'Son Yusuf'); + + const relCount = await page.locator('.rel-row').count(); + record('Family Tree: 3 relationships recorded', relCount === 3, `${relCount} rows`); + + // Verify tree structure: Grandfather is a root, Father nested under him, Son nested under Father + const treeText = await page.locator('.tree-section').innerText(); + const rootShown = treeText.includes('Grandfather Ahmad'); + const spouseShown = treeText.includes('Grandmother Fatimah'); + const fatherNested = treeText.includes('Father Ismail'); + const sonNested = treeText.includes('Son Yusuf'); + record('Family Tree: generational tree renders all 4 people correctly nested', rootShown && spouseShown && fatherNested && sonNested, treeText.replace(/\s+/g, ' ').slice(0, 300)); + + // Photo upload — real file, real Supabase Storage + await page.locator('.person-summary', { hasText: 'Father Ismail' }).click(); + await page.waitForTimeout(400); + const fileInput = page.locator('.person-card', { hasText: 'Father Ismail' }).locator('input[type=file]'); + await fileInput.setInputFiles(path.join(__dirname, 'test-photo.png')); + await page.waitForTimeout(2500); + const avatarImgVisible = await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('img.avatar').isVisible().catch(() => false); + record('Family Tree: photo uploads and renders as the person\'s avatar', avatarImgVisible); + + // Photo removal + if (avatarImgVisible) { + await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('button', { hasText: 'Remove photo' }).click(); + await page.waitForTimeout(1000); + const avatarGoneBack = await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('.avatar-placeholder').isVisible().catch(() => false); + record('Family Tree: removing photo reverts to initial-letter placeholder', avatarGoneBack); + } + + // Edit a person (expand the card first — Edit is only visible when expanded) + await page.locator('.person-summary', { hasText: 'Son Yusuf' }).click(); + await page.waitForTimeout(300); + await page.locator('.person-card', { hasText: 'Son Yusuf' }).locator('button', { hasText: 'Edit' }).click(); + await page.waitForTimeout(300); + await page.locator('.form-card .field:has-text("Notes") input').fill('Eldest grandchild'); + await page.locator('.form-card button.btn-primary', { hasText: 'Save changes' }).click(); + await page.waitForTimeout(600); + // Card should still be expanded (editing doesn't collapse it) — check directly. + const notesVisible = await page.locator('.person-card', { hasText: 'Son Yusuf' }).locator('.notes').isVisible().catch(() => false); + record('Family Tree: editing a person persists notes', notesVisible); + + // Delete a relationship, confirm it's gone + const delBtn = page.locator('.rel-row', { hasText: 'Grandmother Fatimah' }).locator('button'); + await delBtn.click(); + await page.waitForTimeout(600); + const relCountAfterDelete = await page.locator('.rel-row').count(); + record('Family Tree: deleting a relationship removes it', relCountAfterDelete === 2, `${relCountAfterDelete} rows remain`); + + // ── Isolation: a fresh family (same account) should NOT see this tree ── + const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await signInFreshFamily(isolationPage, BASE, 'e2e-family-tree-isolation'); + await isolationPage.locator('nav button[aria-label="Tree"]').click(); + await isolationPage.waitForTimeout(600); + const leakedPerson = await isolationPage.locator('.person-card', { hasText: 'Grandfather Ahmad' }).isVisible().catch(() => false); + record('Family Tree: a different family sees none of this tree (isolation)', !leakedPerson); + await isolationPage.close(); + + record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || ')); + + await browser.close(); + const passCount = results.filter(r => r.pass).length; + const failCount = results.length - passCount; + console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`); + if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`)); + process.exit(failCount > 0 ? 1 : 0); +} +main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); }); diff --git a/src/App.svelte b/src/App.svelte index 5e3070c..e82ffcc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -17,6 +17,7 @@ import FamilySwitcher from './lib/FamilySwitcher.svelte'; import FamilyManagement from './lib/FamilyManagement.svelte'; import MutawalliDashboard from './lib/MutawalliDashboard.svelte'; + import FamilyTree from './lib/FamilyTree.svelte'; let currentLang = $state('en'); lang.subscribe(v => currentLang = v); @@ -40,8 +41,8 @@ } }); - const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Claims (H2)', 'Family', 'Settings']; - const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🔗', '👥', '⚙️']; + const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings']; + const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️']; let activeTab = $state(0); function handleKeydown(e) { @@ -104,9 +105,10 @@ {:else if activeTab === 6} {:else if activeTab === 7} {:else if activeTab === 8} - {:else if activeTab === 9} - {:else if activeTab === 10} - {:else if activeTab === 11} + {:else if activeTab === 9} + {:else if activeTab === 10} + {:else if activeTab === 11} + {:else if activeTab === 12}

Settings

diff --git a/src/lib/FamilyTree.svelte b/src/lib/FamilyTree.svelte new file mode 100644 index 0000000..2197001 --- /dev/null +++ b/src/lib/FamilyTree.svelte @@ -0,0 +1,337 @@ + + +
+
+

Family Tree

+ +
+

Shared family knowledge — any family member can view and edit this map.

+ + {#if error}

{error}

{/if} + +
+

{editingId ? 'Edit person' : 'Add a person'}

+ + + + + +
+ + {#if editingId}{/if} +
+
+ + {#if people.length >= 2} +
+

Add a relationship

+ + + + +
+ {/if} + +
+

Tree

+ {#if roots.length === 0} +

Add people and relationships above to build the tree.

+ {/if} + {#each roots as root (root.id)} + {@render personNode(root, 0)} + {/each} +
+ +
+

All people

+ {#each people as p (p.id)} +
+ + {#if expandedId === p.id} +
+ {#if p.notes}

{p.notes}

{/if} +
+ + {#if photoUrls[p.id]}{/if} +
+
+ + +
+
+ {/if} +
+ {:else} +

No one added yet.

+ {/each} +
+ +
+

Relationships

+ {#each relationships as r (r.id)} +
+ {personName(r.personAId)} {r.type === 'parent_of' ? 'is parent of' : 'is spouse of'} {personName(r.personBId)} + +
+ {:else} +

No relationships recorded yet.

+ {/each} +
+ + +
+ +{#snippet personNode(person, depth)} +
+
+ {#if photoUrls[person.id]} + {person.fullName} + {:else} +
{person.fullName.charAt(0).toUpperCase()}
+ {/if} + {person.fullName} + {#each (spousesOf[person.id] || []) as spouseId} + ⚭ {personName(spouseId)} + {/each} +
+ {#each (childrenOf[person.id] || []) as childId} + {@const child = people.find(p => p.id === childId)} + {#if child} + {@render personNode(child, depth + 1)} + {/if} + {/each} +
+{/snippet} + + diff --git a/src/lib/db.js b/src/lib/db.js index a594ae2..4899d31 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -278,3 +278,80 @@ export async function notifyHeirs(memberId, familyId) { if (error) throw error; return data; } + +// ── Family Genealogy ── +// nf_people are lightweight — most real tree nodes (grandparents, deceased +// relatives, young children) never have a login. linked_user_id is optional. +const PHOTO_BUCKET = 'nf-people-photos'; + +export async function listPeople(familyId) { + const { data, error } = await supabase.from('nf_people').select('*').eq('family_id', familyId).order('created_at'); + if (error) throw error; + return (data || []).map(p => ({ + id: p.id, familyId: p.family_id, linkedUserId: p.linked_user_id, fullName: p.full_name, + gender: p.gender, birthDate: p.birth_date, deathDate: p.death_date, notes: p.notes, photoPath: p.photo_path + })); +} + +export async function addPerson(familyId, createdBy, fields) { + const { data, error } = await supabase.from('nf_people').insert({ + family_id: familyId, created_by: createdBy, full_name: fields.fullName, gender: fields.gender || null, + birth_date: fields.birthDate || null, death_date: fields.deathDate || null, notes: fields.notes || null + }).select().single(); + if (error) throw error; + return data; +} + +export async function updatePerson(id, fields) { + const { error } = await supabase.from('nf_people').update({ + full_name: fields.fullName, gender: fields.gender || null, birth_date: fields.birthDate || null, + death_date: fields.deathDate || null, notes: fields.notes || null, updated_at: new Date().toISOString() + }).eq('id', id); + if (error) throw error; +} + +export async function removePerson(id) { + const { error } = await supabase.from('nf_people').delete().eq('id', id); + if (error) throw error; +} + +/** Uploads a photo for a person and stores its storage path on the record. Path: {familyId}/{personId}/{filename} so RLS can check family membership from the path alone. */ +export async function uploadPersonPhoto(familyId, personId, file) { + const ext = (file.name.split('.').pop() || 'jpg').toLowerCase(); + const path = `${familyId}/${personId}/${Date.now()}.${ext}`; + const { error: upErr } = await supabase.storage.from(PHOTO_BUCKET).upload(path, file, { upsert: true, contentType: file.type }); + if (upErr) throw upErr; + const { error: dbErr } = await supabase.from('nf_people').update({ photo_path: path, updated_at: new Date().toISOString() }).eq('id', personId); + if (dbErr) throw dbErr; + return path; +} + +export async function removePersonPhoto(personId, photoPath) { + if (photoPath) await supabase.storage.from(PHOTO_BUCKET).remove([photoPath]); + const { error } = await supabase.from('nf_people').update({ photo_path: null, updated_at: new Date().toISOString() }).eq('id', personId); + if (error) throw error; +} + +/** Signed URL (private bucket) valid for 1 hour — call fresh each time the photo is displayed. */ +export async function getPersonPhotoUrl(photoPath) { + if (!photoPath) return null; + const { data, error } = await supabase.storage.from(PHOTO_BUCKET).createSignedUrl(photoPath, 3600); + if (error) return null; + return data.signedUrl; +} + +export async function listRelationships(familyId) { + const { data, error } = await supabase.from('nf_relationships').select('*').eq('family_id', familyId); + if (error) throw error; + return (data || []).map(r => ({ id: r.id, personAId: r.person_a_id, personBId: r.person_b_id, type: r.relationship_type })); +} + +export async function addRelationship(familyId, personAId, personBId, type) { + const { error } = await supabase.from('nf_relationships').insert({ family_id: familyId, person_a_id: personAId, person_b_id: personBId, relationship_type: type }); + if (error) throw error; +} + +export async function removeRelationship(id) { + const { error } = await supabase.from('nf_relationships').delete().eq('id', id); + if (error) throw error; +} diff --git a/test-photo.png b/test-photo.png new file mode 100644 index 0000000000000000000000000000000000000000..26a8c68efad2a094e8fe6d850426b651d353c568 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DL?ob7+Dz^)g>OM Q0fiYnUHx3vIVCg!0A@T4ng9R* literal 0 HcmV?d00001