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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user