Files
nur-falah-prevention/src/lib/FamilyTree.svelte
T
wmj 7ce1121b94 feat: add stickiness round — daily Sadaqah tracker + Family Tree social loop
Two features scoped from the stickiness brainstorm, sharing one digest
notification pipeline:

Sadaqah tracker (new tab): a private daily giving journal, not a payment
processor — the app logs, it never moves money. Streak tracking follows
the proven pattern from dedicated apps (Sidq, Daily Sadaqa). Family
visibility is strictly limited to streak counts via a SECURITY DEFINER
RPC (nf_family_sadaqah_streaks) — amounts, causes, and notes never cross
the member boundary, respecting the Islamic preference for giving
privately. Entries can be dedicated 'in memory of' a deceased person from
the Family Tree, with a memorial count (nf_memorial_sadaqah_count, count
only) surfacing on that person's card.

Family Tree social loop: a 'give sadaqah in memory of' link on every
deceased person's card (cross-tab jump via a small requestedTab store in
nav.js), plus completeness hints (missing birth date, missing photo,
no relationships linked) feeding directly into the existing Coverage
Dashboard recommendations engine rather than a new UI surface.

Daily digest Edge Function + pg_cron (07:00 UTC): batches into one email
per family member per day, sent only when there's real content — tree
activity in the last 24h, a birthday/death-anniversary today, or a
weekly sadaqah recap on Sundays. An empty day sends nothing, deliberately
avoiding the notification-spam failure mode the research flagged.
Protected by a shared secret header since it's cron-invoked, not
user-triggered. Verified with a live manual invocation before relying on
the schedule.

Found and fixed two real bugs in nf_family_sadaqah_streaks during E2E
testing: an ambiguous unqualified 'member_id' column reference colliding
with the function's OUT parameter (42702), and a bigint/int type mismatch
from count(*) (42804) — both would have 400'd on every call in production.

Covered by e2e-sadaqah-tree.cjs (12/12). Full regression: 280/280 across
all suites.
2026-08-14 13:42:36 +08:00

358 lines
15 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, getMemorialSadaqahCount
} from './db.js';
import { requestedTab } from './nav.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 memorialCounts = $state({}); // personId -> count
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);
const memorialEntries = await Promise.all(
people.filter(p => p.deathDate).map(async p => [p.id, await getMemorialSadaqahCount(p.id)])
);
memorialCounts = Object.fromEntries(memorialEntries);
}
function giveInMemory() {
requestedTab.set('Sadaqah');
}
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}
{#if p.deathDate}
<div class="memorial-row">
{#if memorialCounts[p.id] > 0}
<span class="memorial-count">{memorialCounts[p.id]} sadaqah given in their memory</span>
{/if}
<button class="btn-small" onclick={giveInMemory}>Give sadaqah in memory of {p.fullName}</button>
</div>
{/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; }
.memorial-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.memorial-count { font-size: 11px; color: #C9A84C; }
.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>