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
+103
View File
@@ -0,0 +1,103 @@
// Verifies the stickiness round: the Sadaqah tracker (log, streak, family
// privacy boundary), the Family Tree memorial-giving link, and the tree
// completeness hints feeding the Coverage Dashboard's recommendations engine.
const { chromium } = require('playwright');
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));
await signInFreshFamily(page, BASE, 'e2e-sadaqah');
// ── Sadaqah tracker ──
await page.locator('nav button[aria-label="Sadaqah"]').click();
await page.waitForTimeout(600);
const zeroStreak = await page.locator('.streak-number').textContent();
record('Sadaqah: starts at 0 streak', zeroStreak.trim() === '0', zeroStreak);
const notYetVisible = await page.locator('.streak-today', { hasText: 'Not yet logged today' }).isVisible().catch(() => false);
record('Sadaqah: shows "not yet logged today" before any entry', notYetVisible);
await page.locator('.field:has-text("Cause") input').fill('Local mosque fund');
await page.locator('.field:has-text("Amount") input').fill('20');
await page.locator('button.btn-primary', { hasText: "Log today's sadaqah" }).click();
await page.waitForTimeout(1200);
const streakAfter = await page.locator('.streak-number').textContent();
record('Sadaqah: logging today brings streak to 1', streakAfter.trim() === '1', streakAfter);
const givenToday = await page.locator('.streak-card.done').isVisible().catch(() => false);
record('Sadaqah: streak card shows "given today" state', givenToday);
const historyRowVisible = await page.locator('.history-row', { hasText: 'Local mosque fund' }).isVisible().catch(() => false);
record('Sadaqah: entry appears in own history with amount', historyRowVisible);
// ── Family Tree: add a deceased person, verify memorial link + dedication ──
await page.locator('nav button[aria-label="Tree"]').click();
await page.waitForTimeout(600);
await page.locator('.form-card .field:has-text("Full name") input').fill('Grandfather Yusuf');
await page.locator('.form-card .field:has-text("Death date") input').fill('2010-05-01');
await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click();
await page.locator('.person-card', { hasText: 'Grandfather Yusuf' }).waitFor({ state: 'visible', timeout: 10000 });
await page.locator('.person-summary', { hasText: 'Grandfather Yusuf' }).click();
await page.waitForTimeout(400);
const memorialButtonVisible = await page.locator('.person-card', { hasText: 'Grandfather Yusuf' }).locator('button', { hasText: 'Give sadaqah in memory of' }).isVisible().catch(() => false);
record('Tree: deceased person shows a "give sadaqah in memory" link', memorialButtonVisible);
await page.locator('.person-card', { hasText: 'Grandfather Yusuf' }).locator('button', { hasText: 'Give sadaqah in memory of' }).click();
await page.waitForTimeout(800);
const jumpedToSadaqah = await page.locator('nav button.tab.active[aria-label="Sadaqah"]').isVisible().catch(() => false);
record('Tree: memorial link navigates to the Sadaqah tab', jumpedToSadaqah);
// Log a dedication and confirm the memorial count appears back on the Tree
await page.locator('.field:has-text("In memory of") select').selectOption({ label: 'Grandfather Yusuf' });
await page.locator('.field:has-text("Cause") input').fill('In his memory');
await page.locator('button.btn-primary', { hasText: "Log today's sadaqah" }).click();
await page.waitForTimeout(1200);
await page.locator('nav button[aria-label="Tree"]').click();
await page.waitForTimeout(600);
await page.locator('.person-summary', { hasText: 'Grandfather Yusuf' }).click();
await page.waitForTimeout(600);
const memorialCountVisible = await page.locator('.memorial-count', { hasText: 'sadaqah given in their memory' }).isVisible().catch(() => false);
record('Tree: memorial sadaqah count appears on the person card', memorialCountVisible);
// ── Coverage Dashboard: tree completeness hints ──
// Add a second, incomplete person (no birth date, no photo, no relationship) to trigger hints.
await page.locator('.form-card .field:has-text("Full name") input').fill('Cousin Zaid');
await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click();
await page.waitForTimeout(1000);
await page.locator('nav button[aria-label="Coverage"]').click();
await page.waitForTimeout(800);
const birthDateHint = await page.locator('.rec-row', { hasText: 'missing a birth date' }).isVisible().catch(() => false);
record('Coverage: recommendations flag people missing a birth date', birthDateHint);
const orphanHint = await page.locator('.rec-row', { hasText: 'no relationships linked' }).isVisible().catch(() => false);
record('Coverage: recommendations flag people with no relationships linked', orphanHint);
// ── Isolation: a different family sees none of this ──
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
await signInFreshFamily(isolationPage, BASE, 'e2e-sadaqah-isolation');
await isolationPage.locator('nav button[aria-label="Sadaqah"]').click();
await isolationPage.waitForTimeout(600);
const isolatedStreak = await isolationPage.locator('.streak-number').textContent();
record('Isolation: a different family starts with a 0 streak, unaffected by the above', isolatedStreak.trim() === '0', isolatedStreak);
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); });
+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]);