7ce1121b94
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.
163 lines
8.4 KiB
Svelte
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>
|