Files
nur-falah-prevention/src/App.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

206 lines
9.4 KiB
Svelte

<script>
import FaraidCalculator from './lib/FaraidCalculator.svelte';
import AssetRegistry from './lib/AssetRegistry.svelte';
import WassiyahGenerator from './lib/WassiyahGenerator.svelte';
import HibahTracker from './lib/HibahTracker.svelte';
import FamilyWaqfDesignator from './lib/FamilyWaqfDesignator.svelte';
import NominationRegistry from './lib/NominationRegistry.svelte';
import CoverageDashboard from './lib/CoverageDashboard.svelte';
import DeathTrigger from './lib/DeathTrigger.svelte';
import DigitalClaims from './lib/horizon2/DigitalClaims.svelte';
import { exportAll, deleteAll } from './lib/storage.js';
import { lang, setLang } from './lib/i18n.js';
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';
import MutawalliDashboard from './lib/MutawalliDashboard.svelte';
import FamilyTree from './lib/FamilyTree.svelte';
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);
let currentSession = $state(null);
session.subscribe(v => currentSession = v);
let loadingAuth = $state(true);
authLoading.subscribe(v => loadingAuth = v);
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
// If the stored active family isn't one this user actually belongs to
// (e.g. after switching accounts), fall back to the family picker.
let familyValid = $state(null);
$effect(() => {
if (currentSession && familyId) {
listMyFamilies().then(fams => { familyValid = fams.some(f => f.id === familyId); });
} else {
familyValid = null;
}
});
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;
if (e.key === 'ArrowLeft') activeTab = (activeTab - 1 + tabs.length) % tabs.length;
}
function doExport() {
const data = exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'nur-falah-full-export.json'; a.click();
URL.revokeObjectURL(url);
}
function doDelete() {
if (confirm('This permanently deletes all locally stored data. This cannot be undone. Continue?')) {
deleteAll();
location.reload();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if loadingAuth}
<div class="loading-screen">Loading…</div>
{:else if !currentSession}
<AuthScreen />
{:else if !familyId || familyValid === false}
<FamilySwitcher />
{:else}
<div class="app">
<header>
<div class="header-brand">
<div class="brand-line brand-line-first">Nur</div>
<div class="brand-line brand-line-second">Falah</div>
</div>
<span class="header-tagline">{currentLang === 'ms' ? 'RANGKAIAN HARTA & WAKAF' : 'ESTATE & WAQF SUITE'}</span>
<div class="header-divider"></div>
</header>
<nav>
{#each tabs as tab, i}
<button class="tab" class:active={activeTab === i} onclick={() => activeTab = i} aria-label={tab}>
<span class="tab-icon">{icons[i]}</span>
<span class="tab-label">{tab}</span>
</button>
{/each}
</nav>
<main>
{#if activeTab === 0}<CoverageDashboard />
{:else if activeTab === 1}<FaraidCalculator />
{:else if activeTab === 2}<AssetRegistry />
{:else if activeTab === 3}<InsurancePolicies />
{:else if activeTab === 4}<ZakatCalculator />
{: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>
<InfoPanel
title="Settings"
what="Where you control your data and the app's language. Estate data (assets, Hibah, Waqf, nominations) now lives in your family's shared account, visible to you and anyone you've assigned as an estate agent."
how="Switch between English and Bahasa Malaysia here. Sign out when you're done, especially on a shared device."
fields={[]}
/>
</div>
<p class="sub">Signed in as {currentSession.user.email}.</p>
<JurisdictionSetting />
<div class="lang-switch">
<span class="lang-label">Language / Bahasa</span>
<div class="lang-buttons">
<button class="lang-btn" class:active={currentLang === 'en'} onclick={() => setLang('en')}>English</button>
<button class="lang-btn" class:active={currentLang === 'ms'} onclick={() => setLang('ms')}>Bahasa Malaysia</button>
</div>
</div>
<button class="btn-secondary" onclick={doExport}>Export local export/H2-demo data (JSON)</button>
<button class="btn-secondary" onclick={signOut}>Sign out</button>
<button class="btn-danger" onclick={doDelete}>Delete local device data irreversible</button>
</div>
{/if}
</main>
</div>
{/if}
<style>
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) {
font-family: 'DM Sans', sans-serif;
background: #070A0D;
color: #E8E4DC;
min-height: 100dvh;
overflow-x: hidden;
position: relative;
}
:global(body::before) {
content: '';
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(201,168,76,0.08) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(46,204,113,0.05) 0%, transparent 60%);
pointer-events: none; z-index: 0;
}
:global(#app) { position: relative; z-index: 2; }
.loading-screen { display: flex; align-items: center; justify-content: center; min-height: 100dvh; color: #8A8478; font-size: 14px; }
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 80px; }
header { text-align: center; padding: 28px 16px 16px; background: rgba(12,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(201,168,76,0.15); position: sticky; top: 0; z-index: 10; }
.header-brand { display: flex; justify-content: center; gap: 8px; }
.brand-line { font-family: 'DM Serif Display', serif; font-size: 26px; }
.brand-line-first { color: #E8E4DC; }
.brand-line-second { color: #C9A84C; }
.header-tagline { font-size: 10px; letter-spacing: 2px; color: #8A8478; }
.header-divider { height: 2px; width: 40px; background: #C9A84C; margin: 10px auto 0; border-radius: 2px; }
nav { display: flex; overflow-x: auto; gap: 4px; padding: 10px 8px; background: rgba(12,17,23,0.7); border-bottom: 1px solid rgba(201,168,76,0.1); position: sticky; top: 92px; z-index: 9; }
.tab { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 12px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
.tab.active { background: rgba(201,168,76,0.12); color: #C9A84C; }
.tab-icon { font-size: 18px; }
.tab-label { font-size: 10px; white-space: nowrap; }
main { flex: 1; padding: 16px; }
:global(.btn-danger) { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.1); color: #EF4444; font-weight: 600; cursor: pointer; margin-top: 12px; }
:global(.btn-secondary) { width: 100%; padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; font-weight: 600; cursor: pointer; }
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
.lang-switch { margin-bottom: 16px; }
.lang-label { display: block; font-size: 12px; color: #B8B2A6; margin-bottom: 8px; }
.lang-buttons { display: flex; gap: 8px; }
.lang-btn { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.05); color: #8A8478; font-size: 13px; cursor: pointer; }
.lang-btn.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); font-weight: 600; }
</style>