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.
This commit is contained in:
2026-08-14 13:42:36 +08:00
parent 9767c0d626
commit 7ce1121b94
8 changed files with 368 additions and 18 deletions
+21 -12
View File
@@ -13,6 +13,7 @@
import InfoPanel from './lib/InfoPanel.svelte';
import { session, authLoading, signOut } from './lib/auth.js';
import { activeFamilyId, listMyFamilies } from './lib/family.js';
import { requestedTab } from './lib/nav.js';
import AuthScreen from './lib/AuthScreen.svelte';
import FamilySwitcher from './lib/FamilySwitcher.svelte';
import FamilyManagement from './lib/FamilyManagement.svelte';
@@ -21,6 +22,7 @@
import InsurancePolicies from './lib/InsurancePolicies.svelte';
import ZakatCalculator from './lib/ZakatCalculator.svelte';
import JurisdictionSetting from './lib/JurisdictionSetting.svelte';
import SadaqahTracker from './lib/SadaqahTracker.svelte';
let currentLang = $state('en');
lang.subscribe(v => currentLang = v);
@@ -44,9 +46,15 @@
}
});
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
let activeTab = $state(0);
requestedTab.subscribe(name => {
if (!name) return;
const idx = tabs.indexOf(name);
if (idx >= 0) activeTab = idx;
requestedTab.set(null);
});
function handleKeydown(e) {
if (e.key === 'ArrowRight') activeTab = (activeTab + 1) % tabs.length;
@@ -104,16 +112,17 @@
{:else if activeTab === 2}<AssetRegistry />
{:else if activeTab === 3}<InsurancePolicies />
{:else if activeTab === 4}<ZakatCalculator />
{:else if activeTab === 5}<WassiyahGenerator />
{:else if activeTab === 6}<HibahTracker />
{:else if activeTab === 7}<FamilyWaqfDesignator />
{:else if activeTab === 8}<NominationRegistry />
{:else if activeTab === 9}<DeathTrigger />
{:else if activeTab === 10}<MutawalliDashboard />
{:else if activeTab === 11}<FamilyTree />
{:else if activeTab === 12}<DigitalClaims />
{:else if activeTab === 13}<FamilyManagement />
{:else if activeTab === 14}
{:else if activeTab === 5}<SadaqahTracker />
{:else if activeTab === 6}<WassiyahGenerator />
{:else if activeTab === 7}<HibahTracker />
{:else if activeTab === 8}<FamilyWaqfDesignator />
{:else if activeTab === 9}<NominationRegistry />
{:else if activeTab === 10}<DeathTrigger />
{:else if activeTab === 11}<MutawalliDashboard />
{:else if activeTab === 12}<FamilyTree />
{:else if activeTab === 13}<DigitalClaims />
{:else if activeTab === 14}<FamilyManagement />
{:else if activeTab === 15}
<div class="module">
<div class="module-header">
<h2>Settings</h2>
+9 -4
View File
@@ -10,7 +10,7 @@
import {
listAssets, listHibahGifts, listNominations, listAllWaqfForFamily,
listWassiyahBequests, listInsurancePolicies, listLiabilities, getZakatRecord,
getMemberTrigger, listMemberAttestors
getMemberTrigger, listMemberAttestors, listPeople, listRelationships
} from './db.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
@@ -25,10 +25,11 @@
async function refresh() {
if (!familyId || !memberId) return;
const [assets, gifts, nominations, waqfDesignations, wassiyah, insurance, liabilities, zakat, members, trigger] = await Promise.all([
const [assets, gifts, nominations, waqfDesignations, wassiyah, insurance, liabilities, zakat, members, trigger, people, relationships] = await Promise.all([
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId),
listWassiyahBequests(familyId, memberId), listInsurancePolicies(familyId, memberId), listLiabilities(familyId),
getZakatRecord(familyId, memberId), listFamilyMembers(familyId), getMemberTrigger(memberId, familyId)
getZakatRecord(familyId, memberId), listFamilyMembers(familyId), getMemberTrigger(memberId, familyId),
listPeople(familyId), listRelationships(familyId)
]);
const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean);
coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds });
@@ -36,6 +37,7 @@
const attestors = trigger ? await listMemberAttestors(memberId, familyId) : [];
const myWaqf = waqfDesignations.find(w => w.author_id === memberId);
const hasCashOrGold = assets.some(a => a.type === 'Cash / Bank' || a.type === 'Jewelry / valuables');
const linkedPersonIds = new Set(relationships.flatMap(r => [r.personAId, r.personBId]));
recommendations = buildRecommendations({
exposedTotal: coverage.exposedTotal,
@@ -49,7 +51,10 @@
unlinkedLiabilityCount: liabilities.filter(l => !l.linkedAssetId).length,
confirmedAttestorCount: attestors.filter(a => a.confirmed).length,
hasAgent: members.some(m => m.role === 'agent' && m.status === 'active'),
waqfConfigured: !!myWaqf
waqfConfigured: !!myWaqf,
missingBirthDateCount: people.filter(p => !p.birthDate).length,
missingPhotoCount: people.filter(p => !p.photoPath).length,
orphanPersonCount: people.filter(p => !linkedPersonIds.has(p.id)).length
});
}
+21 -1
View File
@@ -9,8 +9,9 @@
import {
listPeople, addPerson, updatePerson, removePerson,
uploadPersonPhoto, removePersonPhoto, getPersonPhotoUrl,
listRelationships, addRelationship, removeRelationship
listRelationships, addRelationship, removeRelationship, getMemorialSadaqahCount
} from './db.js';
import { requestedTab } from './nav.js';
import InfoPanel from './InfoPanel.svelte';
import Disclaimer from './Disclaimer.svelte';
@@ -20,6 +21,7 @@
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: '' });
@@ -39,6 +41,14 @@
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);
@@ -239,6 +249,14 @@
{#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')}
@@ -328,6 +346,8 @@
.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; }
+162
View File
@@ -0,0 +1,162 @@
<script>
import { onMount } from 'svelte';
import { activeFamilyId, listFamilyMembers } from './family.js';
import { session } from './auth.js';
import { listSadaqahLog, addSadaqahEntry, removeSadaqahEntry, getFamilySadaqahStreaks, listPeople } from './db.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
// Live off the session store, not a one-time snapshot — same fix as every
// other per-member module in this app: this component remounts on tab
// switch, and a stale null captured once at mount would silently break
// every write until the next remount.
let memberId = $state(null);
session.subscribe(v => memberId = v?.user?.id ?? null);
let log = $state([]);
let familyStreaks = $state([]);
let members = $state([]);
let deceasedPeople = $state([]);
let form = $state(emptyForm());
function emptyForm() {
return { cause: '', amount: '', inMemoryOfPersonId: '', notes: '' };
}
function todayStr() { return new Date().toISOString().slice(0, 10); }
async function refresh() {
if (!familyId || !memberId) return;
const [entries, streaks, fam, people] = await Promise.all([
listSadaqahLog(familyId, memberId), getFamilySadaqahStreaks(familyId), listFamilyMembers(familyId), listPeople(familyId)
]);
log = entries;
familyStreaks = streaks;
members = fam;
deceasedPeople = people.filter(p => p.deathDate);
}
onMount(refresh);
$effect(() => { familyId; memberId; refresh(); });
async function addEntry() {
await addSadaqahEntry(familyId, memberId, { ...form, logDate: todayStr() });
form = emptyForm();
await refresh();
}
async function remove(id) {
await removeSadaqahEntry(id);
await refresh();
}
const myStreak = $derived(familyStreaks.find(s => s.memberId === memberId));
const gaveToday = $derived(log.some(e => e.logDate === todayStr()));
const familyStreakRows = $derived(familyStreaks.map(s => ({
...s, email: members.find(m => m.user_id === s.memberId)?.invited_email || 'Member'
})).filter(r => r.currentStreak > 0 || r.memberId === memberId));
function personName(id) { return deceasedPeople.find(p => p.id === id)?.fullName || ''; }
</script>
<div class="module">
<div class="module-header">
<h2>Sadaqah</h2>
<InfoPanel
title="Sadaqah"
what="A simple journal for daily charity — following the Prophetic practice of giving something every morning and evening. This does not move any money; it's a record of what you already gave elsewhere (your e-wallet, a mosque, a charity), and an optional way to dedicate a gift in memory of someone in your family tree."
how="Log a quick entry after you give — cause, amount if you want to track it, and who it's in memory of if it's a dedication. Your entries stay private; family members only ever see whether you gave today, never amounts or causes."
fields={[
{ label: 'Cause', hint: 'What or who it went to — a mosque, a relief fund, a family member in need.' },
{ label: 'Amount', hint: 'Optional — only you can see this.' },
{ label: 'In memory of', hint: 'Optional — dedicate today\'s giving to a deceased family member from your Tree.' }
]}
/>
</div>
<p class="sub">A private daily giving journal — not a payment tool. Log what you already gave.</p>
<div class="streak-card" class:done={gaveToday}>
<div class="streak-number">{myStreak?.currentStreak || 0}</div>
<div class="streak-label">day{(myStreak?.currentStreak || 0) === 1 ? '' : 's'} in a row</div>
{#if gaveToday}<div class="streak-today">Given today ✓</div>{:else}<div class="streak-today muted">Not yet logged today</div>{/if}
</div>
<div class="form-card">
<label class="field"><span>Cause</span><input type="text" bind:value={form.cause} placeholder="e.g. mosque, relief fund, a relative in need" /></label>
<label class="field"><span>Amount (private, optional)</span><input type="number" min="0" bind:value={form.amount} /></label>
{#if deceasedPeople.length}
<label class="field"><span>In memory of (optional)</span>
<select bind:value={form.inMemoryOfPersonId}>
<option value="">— none —</option>
{#each deceasedPeople as p}<option value={p.id}>{p.fullName}</option>{/each}
</select>
</label>
{/if}
<label class="field"><span>Notes (optional)</span><input type="text" bind:value={form.notes} /></label>
<button class="btn-primary" onclick={addEntry}>Log today's sadaqah</button>
</div>
{#if familyStreakRows.length > 1}
<div class="family-streaks">
<h3>Family</h3>
{#each familyStreakRows as r}
<div class="family-row">
<span>{r.email}</span>
<span class="family-streak-val">{r.currentStreak} day{r.currentStreak === 1 ? '' : 's'}{r.gaveToday ? ' · today ✓' : ''}</span>
</div>
{/each}
<p class="privacy-note">Streaks only — amounts and causes are never shared, even with family.</p>
</div>
{/if}
<div class="history">
<h3>Your history</h3>
{#each log as e (e.id)}
<div class="history-row">
<div class="history-info">
<span class="history-date">{e.logDate}</span>
<span class="history-cause">{e.cause || '—'}{e.inMemoryOfPersonId ? ` · in memory of ${personName(e.inMemoryOfPersonId)}` : ''}</span>
</div>
{#if e.amount}<span class="history-amount">{Number(e.amount).toLocaleString()}</span>{/if}
<button onclick={() => remove(e.id)} aria-label="Remove"></button>
</div>
{:else}
<p class="empty">No entries yet — log your first one above.</p>
{/each}
</div>
<Disclaimer text="This app does not process payments or transfer money. Log what you've already given through your own bank, e-wallet, or charity of choice." />
</div>
<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; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 18px; }
.streak-card { text-align: center; border-radius: 16px; padding: 22px; margin-bottom: 18px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); }
.streak-card.done { background: rgba(46,204,113,0.08); border-color: rgba(46,204,113,0.3); }
.streak-number { font-family: 'DM Serif Display', serif; font-size: 44px; color: #C9A84C; }
.streak-card.done .streak-number { color: #2ECC71; }
.streak-label { font-size: 12px; color: #B8B2A6; margin-top: 2px; }
.streak-today { font-size: 12px; color: #2ECC71; margin-top: 8px; font-weight: 600; }
.streak-today.muted { color: #8A8478; font-weight: 400; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.field select, .field input { 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-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.family-streaks { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 18px; }
.family-streaks h3 { font-size: 14px; color: #C9A84C; margin-bottom: 8px; }
.family-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
.family-streak-val { color: #8A8478; font-size: 12px; }
.privacy-note { font-size: 10.5px; color: #8A8478; margin-top: 8px; font-style: italic; }
.history h3 { font-size: 14px; color: #C9A84C; margin-bottom: 8px; }
.history-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.history-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
.history-date { font-size: 11px; color: #8A8478; }
.history-cause { font-size: 13px; color: #E8E4DC; }
.history-amount { color: #2ECC71; font-weight: 600; font-size: 13px; }
.history-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
</style>
+35
View File
@@ -382,6 +382,41 @@ export async function removeRelationship(id) {
if (error) throw error;
}
// ── Sadaqah — private daily journal. Family visibility is limited to
// streak counts via nf_family_sadaqah_streaks (never amounts/causes/notes).
export async function listSadaqahLog(familyId, memberId) {
const { data, error } = await supabase.from('nf_sadaqah_log').select('*').eq('family_id', familyId).eq('member_id', memberId).order('log_date', { ascending: false });
if (error) throw error;
return (data || []).map(s => ({
id: s.id, logDate: s.log_date, cause: s.cause, amount: s.amount,
inMemoryOfPersonId: s.in_memory_of_person_id, notes: s.notes
}));
}
export async function addSadaqahEntry(familyId, memberId, fields) {
const { error } = await supabase.from('nf_sadaqah_log').insert({
family_id: familyId, member_id: memberId, log_date: fields.logDate || new Date().toISOString().slice(0, 10),
cause: fields.cause || null, amount: fields.amount ? Number(fields.amount) : null,
in_memory_of_person_id: fields.inMemoryOfPersonId || null, notes: fields.notes || null
});
if (error) throw error;
}
export async function removeSadaqahEntry(id) {
const { error } = await supabase.from('nf_sadaqah_log').delete().eq('id', id);
if (error) throw error;
}
/** Family-visible streak counts only — no amounts, causes, or notes. */
export async function getFamilySadaqahStreaks(familyId) {
const { data, error } = await supabase.rpc('nf_family_sadaqah_streaks', { p_family_id: familyId });
if (error) throw error;
return (data || []).map(r => ({ memberId: r.member_id, currentStreak: r.current_streak, gaveToday: r.gave_today }));
}
/** Count of sadaqah given in memory of a person — count only, shown on their Tree card. */
export async function getMemorialSadaqahCount(personId) {
const { data, error } = await supabase.rpc('nf_memorial_sadaqah_count', { p_person_id: personId });
if (error) throw error;
return data || 0;
}
// ── Insurance / Takaful — per-member: each family member logs their own
// policies, but any family member (and the mutawalli dashboard) can read them. ──
export async function listInsurancePolicies(familyId, memberId) {
+6
View File
@@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
// Cross-component tab navigation — App.svelte owns the actual tab index
// locally (not a store), so this is a one-shot request channel: set the
// tab's display name here, App.svelte jumps to it and clears the request.
export const requestedTab = writable(null);
+11 -1
View File
@@ -8,7 +8,8 @@ export function buildRecommendations(data) {
const {
exposedTotal = 0, exposedCount = 0, unverifiedCount = 0, wassiyahCount = 0, estateTotal = 0,
insuranceCount = 0, zakatConfigured = false, hasCashOrGold = false, unlinkedLiabilityCount = 0,
confirmedAttestorCount = 0, hasAgent = false, waqfConfigured = false
confirmedAttestorCount = 0, hasAgent = false, waqfConfigured = false,
missingBirthDateCount = 0, missingPhotoCount = 0, orphanPersonCount = 0
} = data;
const items = [];
@@ -44,6 +45,15 @@ export function buildRecommendations(data) {
if (estateTotal > 0 && !waqfConfigured) {
items.push({ severity: 'low', text: 'No Waqf designation set up. If any part of your estate is meant as a lasting charitable endowment, this is where to set it aside.', tab: 'Family Waqf' });
}
if (orphanPersonCount > 0) {
items.push({ severity: 'low', text: `${orphanPersonCount} ${orphanPersonCount === 1 ? 'person has' : 'people have'} no relationships linked in your Family Tree — is a parent, child, or spouse missing?`, tab: 'Tree' });
}
if (missingBirthDateCount > 0) {
items.push({ severity: 'low', text: `${missingBirthDateCount} ${missingBirthDateCount === 1 ? 'person is' : 'people are'} missing a birth date in the Family Tree.`, tab: 'Tree' });
}
if (missingPhotoCount > 0) {
items.push({ severity: 'low', text: `${missingPhotoCount} ${missingPhotoCount === 1 ? 'person has' : 'people have'} no photo in the Family Tree.`, tab: 'Tree' });
}
const order = { high: 0, medium: 1, low: 2 };
return items.sort((a, b) => order[a.severity] - order[b.severity]);