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

163 lines
8.4 KiB
Svelte

<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>