feat: add Khairat, emergency-contact auto-notify, and 5 lifestyle-companion tabs

Big round covering two different asks: Khairat/emergency infrastructure
(tightly coupled to what's already built) and a Muslim-lifestyle
companion surface (a genuinely different product, added at the user's
explicit request after being offered a smaller scope).

Khairat & emergency contacts:
- nf_khairat_memberships (per-member scheme membership) + nf_emergency_fund
  (per-family shared reserve tracker) — records intent/contacts only,
  never holds or moves real money.
- nf_trusted_contacts gains category (mosque/police/ambulance/hospital/
  khairat/family/other) and email columns.
- New notify-emergency-contacts Edge Function, auto-invoked the moment a
  mutawalli fires a member's death trigger (the one place in this app
  where auto-send-on-trigger is actually correct), alongside — not
  instead of — the existing heir notification. Verified with a live fire
  end-to-end (e2e-emergency-notify.cjs, 5/5).

Lifestyle-companion tabs — all computed/searched live, nothing fabricated:
- Qibla: pure great-circle bearing math to the Kaaba from geolocation,
  no API/key. Verified against Kuala Lumpur (293°, matches expected ~292°).
- Prayer Times: client-side astronomical calculation (single-pass solar
  position, MWL angles), sanity-checked against known KL/London times
  before shipping.
- Locate: mosque/halal/cemetery search via the free, keyless OpenStreetMap
  Overpass API — real community-sourced results only, honest empty state
  when nothing's mapped nearby.
- Quran: Surah list + Arabic/translation via the free alquran.cloud API,
  fetched fresh each time, nothing stored in this app's own database.
- Neighbourhood: a join-by-code community announcement board — a
  genuinely separate multi-tenant concept from the estate-planning family
  structure, scoped to the user account. Found and fixed a real UX gap
  during testing: the create/join form was only reachable with zero
  existing neighbourhoods, with no way to join a second one.

Also found and fixed a real bug: the heir-notify and emergency-notify
status messages shared the same CSS class, breaking any script (including
the pre-existing e2e-per-member.cjs) that targeted '.notify-status'
without further filtering — gave each its own distinguishing class.

Covered by e2e-khairat-lifestyle.cjs (13/13) and e2e-emergency-notify.cjs
(5/5). Full regression: 316/316 across all suites (several transient
flakes under heavy mail-relay load during the sweep, all confirmed clean
on rerun — one led to the real class-collision fix above).
This commit is contained in:
2026-08-14 14:25:26 +08:00
parent 7ce1121b94
commit 2549e9de0c
15 changed files with 1339 additions and 24 deletions
+14 -6
View File
@@ -23,7 +23,8 @@
let form = $state(emptyForm());
let editingId = $state(null);
let contactForm = $state({ name: '', method: '' });
let contactForm = $state({ name: '', method: '', category: 'family', email: '' });
const CONTACT_CATEGORIES = ['family', 'mosque', 'khairat', 'police', 'ambulance', 'hospital', 'other'];
let liabilityForm = $state(emptyLiabilityForm());
let editingLiabilityId = $state(null);
@@ -74,8 +75,8 @@
async function addContact() {
if (!contactForm.name) return;
await addTrustedContact(familyId, contactForm.name, contactForm.method);
contactForm = { name: '', method: '' };
await addTrustedContact(familyId, contactForm.name, contactForm.method, contactForm.category, contactForm.email);
contactForm = { name: '', method: '', category: 'family', email: '' };
await refresh();
}
@@ -226,14 +227,20 @@
<div class="contacts-section">
<h3>Trusted contacts</h3>
<p class="note">Notified or given a read-only export on a triggering event — not an automated death-detection or legal-transfer mechanism.</p>
<p class="note">Notified automatically by email when your mutawalli fires your death trigger — mosque, khairat officer, police, ambulance/hospital, or family. Not an automated death-detection mechanism.</p>
<div class="form-card">
<label class="field"><span>Name</span><input type="text" bind:value={contactForm.name} /></label>
<label class="field"><span>Contact method</span><input type="text" bind:value={contactForm.method} placeholder="email or phone" /></label>
<label class="field"><span>Category</span>
<select bind:value={contactForm.category}>
{#each CONTACT_CATEGORIES as cat}<option value={cat}>{cat}</option>{/each}
</select>
</label>
<label class="field"><span>Contact method (phone, etc.)</span><input type="text" bind:value={contactForm.method} placeholder="e.g. phone number" /></label>
<label class="field"><span>Email (for automatic notification)</span><input type="email" bind:value={contactForm.email} placeholder="required to auto-notify this contact" /></label>
<button class="btn-secondary" onclick={addContact}>Add trusted contact</button>
</div>
{#each trustedContacts as c (c.id)}
<div class="contact-row"><span>{c.name}{c.method}</span><button onclick={() => removeContact(c.id)}>✕</button></div>
<div class="contact-row"><span><span class="contact-category">{c.category}</span> {c.name}{c.method}{c.email ? ` · ${c.email}` : ''}</span><button onclick={() => removeContact(c.id)}>✕</button></div>
{/each}
</div>
@@ -332,6 +339,7 @@
.contacts-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 4px; }
.note { font-size: 11.5px; color: #8A8478; margin-bottom: 12px; }
.contact-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
.contact-category { font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; color: #C9A84C; background: rgba(201,168,76,0.1); padding: 1px 6px; border-radius: 4px; margin-right: 6px; }
.verify-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 0 12px; margin-top: -6px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.verify-badge { font-size: 10.5px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,0.06); color: #8A8478; }
.verify-badge.verified { background: rgba(46,204,113,0.15); color: #2ECC71; }