From 2549e9de0cc4c0ec03f527137f69bcfd39a752ee Mon Sep 17 00:00:00 2001 From: Wmj Ismail Date: Fri, 14 Aug 2026 14:25:26 +0800 Subject: [PATCH] feat: add Khairat, emergency-contact auto-notify, and 5 lifestyle-companion tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- e2e-emergency-notify.cjs | 88 ++++++++++++++ e2e-khairat-lifestyle.cjs | 148 +++++++++++++++++++++++ e2e-per-member.cjs | 4 +- src/App.svelte | 36 ++++-- src/lib/AssetRegistry.svelte | 20 +++- src/lib/KhairatTracker.svelte | 140 ++++++++++++++++++++++ src/lib/Locators.svelte | 103 ++++++++++++++++ src/lib/MutawalliDashboard.svelte | 23 +++- src/lib/NeighbourhoodBoard.svelte | 187 ++++++++++++++++++++++++++++++ src/lib/PrayerTimes.svelte | 111 ++++++++++++++++++ src/lib/QiblaFinder.svelte | 133 +++++++++++++++++++++ src/lib/QuranReader.svelte | 133 +++++++++++++++++++++ src/lib/calc/prayerTimes.js | 89 ++++++++++++++ src/lib/db.js | 89 +++++++++++++- src/lib/overpass.js | 59 ++++++++++ 15 files changed, 1339 insertions(+), 24 deletions(-) create mode 100644 e2e-emergency-notify.cjs create mode 100644 e2e-khairat-lifestyle.cjs create mode 100644 src/lib/KhairatTracker.svelte create mode 100644 src/lib/Locators.svelte create mode 100644 src/lib/NeighbourhoodBoard.svelte create mode 100644 src/lib/PrayerTimes.svelte create mode 100644 src/lib/QiblaFinder.svelte create mode 100644 src/lib/QuranReader.svelte create mode 100644 src/lib/calc/prayerTimes.js create mode 100644 src/lib/overpass.js diff --git a/e2e-emergency-notify.cjs b/e2e-emergency-notify.cjs new file mode 100644 index 0000000..dd7d87b --- /dev/null +++ b/e2e-emergency-notify.cjs @@ -0,0 +1,88 @@ +// Verifies that firing a member's death trigger automatically invokes the +// notify-emergency-contacts Edge Function (mosque/khairat/police/ambulance +// contacts) alongside the existing heir notification — the one place in +// this app where "auto-send on trigger" is actually the right behavior. +const { chromium } = require('playwright'); +const BASE = 'https://moslem04.falahos.my/'; +const OWNER = 'nurfalah.e2etest.owner@gmail.com'; +const AGENT = 'nurfalah.e2etest.agent@gmail.com'; +const PASSWORD = 'TestPassword123!'; +const results = []; +function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); } + +async function signIn(page, email) { + await page.goto(BASE, { waitUntil: 'networkidle' }); + await page.locator('.field:has-text("Email") input').fill(email); + await page.locator('.field:has-text("Password") input').fill(PASSWORD); + await page.locator('button.btn-primary', { hasText: 'Sign in' }).click(); + await page.waitForTimeout(1500); +} + +async function main() { + const familyName = `EmergencyNotify ${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const browser = await chromium.launch(); + + const ownerCtx = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const ownerPage = await ownerCtx.newPage(); + await signIn(ownerPage, OWNER); + await ownerPage.locator('.field:has-text("Family name") input').fill(familyName); + await ownerPage.locator('button.btn-primary', { hasText: 'Create family' }).click(); + await ownerPage.waitForTimeout(1200); + + await ownerPage.locator('nav button[aria-label="Assets"]').click(); + await ownerPage.waitForTimeout(500); + await ownerPage.locator('.contacts-section .field:has-text("Name") input').fill('Emergency Test Mosque'); + await ownerPage.locator('.contacts-section .field:has-text("Category") select').selectOption('mosque'); + await ownerPage.locator('.contacts-section .field:has-text("Email") input').fill('mosque-emergency-test@example.com'); + await ownerPage.locator('.contacts-section button.btn-secondary', { hasText: 'Add trusted contact' }).click(); + await ownerPage.waitForTimeout(1000); + record('Setup: mosque contact with email added', await ownerPage.locator('.contact-row', { hasText: 'Emergency Test Mosque' }).isVisible().catch(() => false)); + + await ownerPage.locator('nav button[aria-label="Family"]').click(); + await ownerPage.waitForTimeout(500); + await ownerPage.locator('.field:has-text("Invite by email") input').fill(AGENT); + await ownerPage.locator('.field:has-text("Role") select').selectOption('agent'); + await ownerPage.locator('button.btn-primary', { hasText: 'Send invite' }).click(); + await ownerPage.waitForTimeout(1000); + + const agentCtx = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const agentPage = await agentCtx.newPage(); + let notifyStatus = null; + agentPage.on('response', r => { if (r.url().includes('functions/v1/notify-emergency-contacts')) notifyStatus = r.status(); }); + await signIn(agentPage, AGENT); + const pending = agentPage.locator('.invite-row', { hasText: familyName }); + if (await pending.count()) { await pending.locator('button', { hasText: 'Accept' }).click(); await agentPage.waitForTimeout(1000); } + const familyRow = agentPage.locator('.family-row', { hasText: familyName }); + if (await familyRow.count()) { await familyRow.click(); await agentPage.waitForTimeout(1000); } + + await agentPage.locator('nav button[aria-label="Mutawalli"]').click(); + await agentPage.waitForTimeout(1000); + const memberChip = agentPage.locator('.member-chip', { hasText: 'nurfalah.e2etest.owner' }); + record('Mutawalli: owner appears as a member to fire a trigger for', await memberChip.count() === 1); + if (await memberChip.count()) await memberChip.click(); + await agentPage.waitForTimeout(500); + + const attestorInputs = agentPage.locator('.attestor-row input'); + await attestorInputs.nth(0).fill('Witness A'); + await agentPage.locator('.attestor-row .confirm-btn').nth(0).click(); + await attestorInputs.nth(1).fill('Witness B'); + await agentPage.locator('.attestor-row .confirm-btn').nth(1).click(); + await agentPage.locator('.field:has-text("Date of death") input').fill('2026-01-01'); + await agentPage.locator('.field:has-text("Death certificate") input').fill(`CERT-${Date.now()}`); + await agentPage.waitForTimeout(500); + + await agentPage.locator('button.btn-danger-solid').click(); + await agentPage.waitForTimeout(4000); + + record('Trigger: fires successfully (triggered banner shown)', await agentPage.locator('.triggered-banner').isVisible().catch(() => false)); + record('Emergency notify: auto-invoked on fire, returns 200', notifyStatus === 200, `status: ${notifyStatus}`); + record('Emergency notify: status message shown to the mutawalli', await agentPage.locator('.emergency-notify-status', { hasText: 'Notified' }).isVisible().catch(() => false)); + + 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); }); diff --git a/e2e-khairat-lifestyle.cjs b/e2e-khairat-lifestyle.cjs new file mode 100644 index 0000000..4055c85 --- /dev/null +++ b/e2e-khairat-lifestyle.cjs @@ -0,0 +1,148 @@ +// Verifies the Khairat/emergency-contacts round and the lifestyle-companion +// tabs (Qibla, Prayer Times, Locate, Quran, Neighbourhood) against the live +// backend. Geolocation-dependent tabs use Playwright's mocked geolocation +// (Kuala Lumpur coordinates) rather than the real device. +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 context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + geolocation: { latitude: 3.1390, longitude: 101.6869 }, // Kuala Lumpur + permissions: ['geolocation'] + }); + const page = await context.newPage(); + 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-khairat'); + + // ── Khairat ── + await page.locator('nav button[aria-label="Khairat"]').click(); + await page.waitForTimeout(600); + await page.locator('.field:has-text("Scheme name") input').fill('Kariah Khairat Kematian Masjid Al-Falah'); + await page.locator('.field:has-text("Organization") input').fill('Masjid Al-Falah'); + await page.locator('button.btn-primary', { hasText: 'Add khairat membership' }).click(); + await page.waitForTimeout(1000); + const khairatRowVisible = await page.locator('.khairat-row', { hasText: 'Kariah Khairat Kematian' }).isVisible().catch(() => false); + record('Khairat: membership added and listed', khairatRowVisible); + + await page.locator('.fund-card .field:has-text("Target amount") input').fill('5000'); + await page.locator('.fund-card .field:has-text("Current balance") input').fill('1250'); + await page.waitForTimeout(1000); + const fundProgressVisible = await page.locator('.fund-progress-label', { hasText: '25%' }).isVisible().catch(() => false); + record('Khairat: emergency fund progress bar computes correctly (1250/5000 = 25%)', fundProgressVisible); + + // ── Trusted contacts with category + email ── + await page.locator('nav button[aria-label="Assets"]').click(); + await page.waitForTimeout(500); + await page.locator('.contacts-section .field:has-text("Name") input').fill('Masjid Al-Falah Office'); + await page.locator('.contacts-section .field:has-text("Category") select').selectOption('mosque'); + await page.locator('.contacts-section .field:has-text("Email") input').fill('office@example-mosque.test'); + await page.locator('.contacts-section button.btn-secondary', { hasText: 'Add trusted contact' }).click(); + await page.waitForTimeout(1000); + const contactCategoryVisible = await page.locator('.contact-row', { hasText: 'Masjid Al-Falah Office' }).locator('.contact-category', { hasText: 'mosque' }).isVisible().catch(() => false); + record('Trusted Contacts: category badge shows on the new contact', contactCategoryVisible); + + // ── Qibla ── + await page.locator('nav button[aria-label="Qibla"]').click(); + await page.waitForTimeout(500); + await page.locator('button.btn-primary', { hasText: 'Find Qibla direction' }).click(); + await page.waitForTimeout(2000); + const bearingVisible = await page.locator('.bearing-readout strong').isVisible().catch(() => false); + const bearingText = bearingVisible ? await page.locator('.bearing-readout strong').textContent() : ''; + record('Qibla: computes a bearing from Kuala Lumpur (expect ~292° toward Makkah)', bearingVisible && /29[0-5]°/.test(bearingText), bearingText); + const distanceVisible = await page.locator('.distance-note', { hasText: 'km to Makkah' }).isVisible().catch(() => false); + record('Qibla: shows distance to Makkah', distanceVisible); + + // ── Prayer Times ── + await page.locator('nav button[aria-label="Prayer Times"]').click(); + await page.waitForTimeout(500); + await page.locator('button.btn-primary', { hasText: "Calculate today's prayer times" }).click(); + await page.waitForTimeout(2000); + const fajrVisible = await page.locator('.time-row', { hasText: 'Fajr' }).locator('.time-value').isVisible().catch(() => false); + const timesInOrder = await page.locator('.times-list').innerText(); + record('Prayer Times: computes all 6 times for Kuala Lumpur', fajrVisible && /Fajr[\s\S]*Sunrise[\s\S]*Dhuhr[\s\S]*Asr[\s\S]*Maghrib[\s\S]*Isha/.test(timesInOrder), timesInOrder.replace(/\n/g, ' ')); + + // ── Locate (mosque search via live OpenStreetMap Overpass) ── + await page.locator('nav button[aria-label="Locate"]').click(); + await page.waitForTimeout(500); + await page.locator('button.btn-primary', { hasText: 'Search nearby' }).click(); + await page.waitForTimeout(8000); // live Overpass API call + const locateResultOrEmpty = await page.locator('.results-list, .empty').isVisible().catch(() => false); + record('Locate: mosque search completes (real results or honest empty state)', locateResultOrEmpty); + + // ── Quran ── + await page.locator('nav button[aria-label="Quran"]').click(); + await page.waitForTimeout(2000); + const surahListVisible = await page.locator('.surah-row', { hasText: 'Al-Faatiha' }).isVisible().catch(() => false); + record('Quran: Surah list loads from the public API (Al-Faatiha present)', surahListVisible); + + if (surahListVisible) { + await page.locator('.surah-row', { hasText: 'Al-Faatiha' }).click(); + await page.waitForTimeout(2000); + const ayahVisible = await page.locator('.ayah-row').first().isVisible().catch(() => false); + const arabicVisible = await page.locator('.ayah-arabic').first().isVisible().catch(() => false); + record('Quran: opening a Surah loads Arabic text and translation', ayahVisible && arabicVisible); + } + + // ── Neighbourhood board ── + // Neighbourhoods are scoped to the user account, not the family — the + // shared e2e owner account may already belong to one from a prior run, + // in which case the create/join form is behind the "add another" toggle. + await page.locator('nav button[aria-label="Neighbourhood"]').click(); + await page.waitForTimeout(600); + const addToggleVisible = await page.locator('.add-toggle').isVisible().catch(() => false); + if (addToggleVisible) await page.locator('.add-toggle').click(); + await page.waitForTimeout(300); + const nName = `Kariah Test ${Date.now()}`; + await page.locator('.form-card .field:has-text("Name") input').fill(nName); + await page.locator('button.btn-primary', { hasText: 'Create & get a join code' }).click(); + await page.waitForTimeout(1000); + const joinCodeVisible = await page.locator('.join-code', { hasText: 'Join code:' }).isVisible().catch(() => false); + record('Neighbourhood: creating one generates a join code', joinCodeVisible); + + await page.locator('.form-card .field:has-text("Title") input').fill('Friday khutbah reminder'); + await page.locator('button.btn-primary', { hasText: 'Post announcement' }).click(); + await page.waitForTimeout(1000); + const postVisible = await page.locator('.post-row', { hasText: 'Friday khutbah reminder' }).isVisible().catch(() => false); + record('Neighbourhood: posting an announcement shows it in the board', postVisible); + + // Join-by-code isolation: a second account joining via the real code should see the same post + const joinCodeText = await page.locator('.join-code').textContent(); + const code = joinCodeText.replace('Join code:', '').trim(); + + // signInFreshFamily always signs in as the same fixed e2e owner account + // (just a new family) — since neighbourhoods are user-scoped, this "second + // account" already belongs to the neighbourhood created above, so the + // join form is behind the same toggle as before. + const secondContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const secondPage = await secondContext.newPage(); + await signInFreshFamily(secondPage, BASE, 'e2e-khairat-neighbour2'); + await secondPage.locator('nav button[aria-label="Neighbourhood"]').click(); + await secondPage.waitForTimeout(600); + const secondAddToggleVisible = await secondPage.locator('.add-toggle').isVisible().catch(() => false); + if (secondAddToggleVisible) await secondPage.locator('.add-toggle').click(); + await secondPage.waitForTimeout(300); + await secondPage.locator('.form-card .field:has-text("Join code") input').fill(code); + await secondPage.locator('button.btn-secondary', { hasText: 'Join' }).click(); + await secondPage.waitForTimeout(1000); + const secondSeesPost = await secondPage.locator('.post-row', { hasText: 'Friday khutbah reminder' }).isVisible().catch(() => false); + record('Neighbourhood: a second account joining by code sees the same posts', secondSeesPost); + await secondContext.close(); + + record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.slice(0, 5).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); }); diff --git a/e2e-per-member.cjs b/e2e-per-member.cjs index fa1fc74..e87c250 100644 --- a/e2e-per-member.cjs +++ b/e2e-per-member.cjs @@ -127,8 +127,8 @@ async function main() { const notifyBtn = agentPage.locator('button.btn-secondary', { hasText: 'Notify heirs' }); if (await notifyBtn.isVisible().catch(() => false)) { await notifyBtn.click(); - await agentPage.locator('.notify-status', { hasText: /Sent|Failed/ }).waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}); - const statusText = await agentPage.locator('.notify-status').textContent().catch(() => ''); + await agentPage.locator('.heir-notify-status', { hasText: /Sent|Failed/ }).waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}); + const statusText = await agentPage.locator('.heir-notify-status').textContent().catch(() => ''); record('Mutawalli: heir notification actually sends via the real SMTP relay', statusText.includes('Sent'), statusText); } } diff --git a/src/App.svelte b/src/App.svelte index acc5062..e1f335f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -23,6 +23,12 @@ import ZakatCalculator from './lib/ZakatCalculator.svelte'; import JurisdictionSetting from './lib/JurisdictionSetting.svelte'; import SadaqahTracker from './lib/SadaqahTracker.svelte'; + import KhairatTracker from './lib/KhairatTracker.svelte'; + import QiblaFinder from './lib/QiblaFinder.svelte'; + import PrayerTimes from './lib/PrayerTimes.svelte'; + import Locators from './lib/Locators.svelte'; + import QuranReader from './lib/QuranReader.svelte'; + import NeighbourhoodBoard from './lib/NeighbourhoodBoard.svelte'; let currentLang = $state('en'); lang.subscribe(v => currentLang = v); @@ -46,8 +52,8 @@ } }); - const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings']; - const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️']; + const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Khairat', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Prayer Times', 'Qibla', 'Locate', 'Quran', 'Neighbourhood', 'Claims (H2)', 'Family', 'Settings']; + const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '🆘', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🕌', '🧭', '📍', '📖', '📢', '🔗', '👥', '⚙️']; let activeTab = $state(0); requestedTab.subscribe(name => { if (!name) return; @@ -113,16 +119,22 @@ {:else if activeTab === 3} {:else if activeTab === 4} {:else if activeTab === 5} - {:else if activeTab === 6} - {:else if activeTab === 7} - {:else if activeTab === 8} - {:else if activeTab === 9} - {:else if activeTab === 10} - {:else if activeTab === 11} - {:else if activeTab === 12} - {:else if activeTab === 13} - {:else if activeTab === 14} - {:else if activeTab === 15} + {:else if activeTab === 6} + {:else if activeTab === 7} + {:else if activeTab === 8} + {:else if activeTab === 9} + {:else if activeTab === 10} + {:else if activeTab === 11} + {:else if activeTab === 12} + {:else if activeTab === 13} + {:else if activeTab === 14} + {:else if activeTab === 15} + {:else if activeTab === 16} + {:else if activeTab === 17} + {:else if activeTab === 18} + {:else if activeTab === 19} + {:else if activeTab === 20} + {:else if activeTab === 21}

Settings

diff --git a/src/lib/AssetRegistry.svelte b/src/lib/AssetRegistry.svelte index 6dfa791..7b6d938 100644 --- a/src/lib/AssetRegistry.svelte +++ b/src/lib/AssetRegistry.svelte @@ -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 @@

Trusted contacts

-

Notified or given a read-only export on a triggering event — not an automated death-detection or legal-transfer mechanism.

+

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.

- + + +
{#each trustedContacts as c (c.id)} -
{c.name} — {c.method}
+
{c.category} {c.name} — {c.method}{c.email ? ` · ${c.email}` : ''}
{/each}
@@ -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; } diff --git a/src/lib/KhairatTracker.svelte b/src/lib/KhairatTracker.svelte new file mode 100644 index 0000000..4e3d741 --- /dev/null +++ b/src/lib/KhairatTracker.svelte @@ -0,0 +1,140 @@ + + +
+
+

Khairat

+ +
+

Your khairat scheme membership, plus a shared family emergency fund.

+ +
+ + + + + + + +
+ +
+ {#each memberships as k (k.id)} +
+
+ {k.schemeName} + {k.organization || '—'}{k.membershipNumber ? ` · #${k.membershipNumber}` : ''} + {k.contactPhone || ''}{k.contactPhone && k.contactEmail ? ' · ' : ''}{k.contactEmail || ''} +
+ +
+ {:else} +

No khairat membership logged yet — most families should have at least one.

+ {/each} +
+ +
+

Family emergency fund

+
+ + + + {#if Number(fund.targetAmount) > 0} +
+

{fundProgress}% of target

+ {/if} +
+
+ + +
+ + diff --git a/src/lib/Locators.svelte b/src/lib/Locators.svelte new file mode 100644 index 0000000..4fbb6df --- /dev/null +++ b/src/lib/Locators.svelte @@ -0,0 +1,103 @@ + + +
+
+

Locate

+ +
+

Real, live-searched results from OpenStreetMap — not a curated directory.

+ +
+ {#each CATEGORIES as c} + + {/each} +
+ + {#if status === 'idle'} + + {:else if status === 'locating'} +

Getting your location…

+ {:else if status === 'searching'} +

Searching OpenStreetMap…

+ {:else if status === 'error'} +

{errorMsg}

+ + {:else if status === 'ready'} + {#if results.length === 0} +

Nothing found nearby in OpenStreetMap's {categoryLabel(category).toLowerCase()} data. It may not be mapped yet in your area.

+ {:else} + + {/if} + + {/if} + + +
+ + diff --git a/src/lib/MutawalliDashboard.svelte b/src/lib/MutawalliDashboard.svelte index 3ff8359..a6eddd7 100644 --- a/src/lib/MutawalliDashboard.svelte +++ b/src/lib/MutawalliDashboard.svelte @@ -10,7 +10,7 @@ listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily, listAllWassiyahSettingsForFamily, getMemberTrigger, upsertMemberTriggerSetup, fireMemberTrigger, listMemberAttestors, addMemberAttestor, updateMemberAttestorName, setMemberAttestorConfirmed, - notifyHeirs + notifyHeirs, notifyEmergencyContacts } from './db.js'; import InfoPanel from './InfoPanel.svelte'; import Disclaimer from './Disclaimer.svelte'; @@ -33,6 +33,7 @@ let deathCertRef = $state(''); let error = $state(''); let notifyStatus = $state(''); + let emergencyNotifyStatus = $state(''); async function refresh() { if (!familyId) return; @@ -107,6 +108,12 @@ await upsertMemberTriggerSetup(selectedMemberId, familyId, { executorName, dateOfDeath, deathCertRef }); await fireMemberTrigger(selectedMemberId, familyId, currentUser()?.id); trigger = await getMemberTrigger(selectedMemberId, familyId); + // Auto-notify emergency contacts (mosque, khairat, police, ambulance/ + // hospital) the moment the trigger fires — this is the one event + // where "auto-send" is actually appropriate, not optional. Best-effort: + // a failure here shouldn't make the trigger fire itself look failed. + try { emergencyNotifyStatus = await notifyEmergencyContacts(selectedMemberId, familyId, null).then(r => `Notified ${r.sent} emergency contact(s).`); } + catch (e) { emergencyNotifyStatus = 'Emergency contact notification failed: ' + e.message; } } catch (e) { error = e.message; } @@ -122,6 +129,16 @@ } } + async function resendEmergencyNotifications() { + emergencyNotifyStatus = 'Sending…'; + try { + const r = await notifyEmergencyContacts(selectedMemberId, familyId, null); + emergencyNotifyStatus = `Notified ${r.sent} emergency contact(s).`; + } catch (e) { + emergencyNotifyStatus = 'Failed: ' + e.message; + } + } + function selectedEmail() { return members.find(m => m.user_id === selectedMemberId)?.invited_email || ''; } @@ -186,7 +203,9 @@ {#if trigger?.triggered}
Triggered {trigger.triggered_at?.slice(0, 10)}.
- {#if notifyStatus}

{notifyStatus}

{/if} + {#if notifyStatus}

{notifyStatus}

{/if} + + {#if emergencyNotifyStatus}

{emergencyNotifyStatus}

{/if} {:else}
diff --git a/src/lib/NeighbourhoodBoard.svelte b/src/lib/NeighbourhoodBoard.svelte new file mode 100644 index 0000000..c195b0b --- /dev/null +++ b/src/lib/NeighbourhoodBoard.svelte @@ -0,0 +1,187 @@ + + +
+
+

Neighbourhood

+ +
+

A community board, independent of your estate-planning family.

+ + {#if neighbourhoods.length > 1} +
+ {#each neighbourhoods as n} + + {/each} +
+ {/if} + + {#if activeNeighbourhood} +
+
+ {activeNeighbourhood.name} + Join code: {activeNeighbourhood.joinCode} +
+ +
+ +
+ + + +
+ +
+ {#each posts as p (p.id)} +
+
+ {p.title} + {#if p.author_id === userId}{/if} +
+ {#if p.body}

{p.body}

{/if} + +
+ {:else} +

No announcements yet.

+ {/each} +
+ + + {/if} + + {#if showAddForm || !activeNeighbourhood} +
+

Start a neighbourhood

+ + +
+
+

Join with a code

+ + +
+ {#if error}

{error}

{/if} + {/if} + + +
+ + diff --git a/src/lib/PrayerTimes.svelte b/src/lib/PrayerTimes.svelte new file mode 100644 index 0000000..4cecf33 --- /dev/null +++ b/src/lib/PrayerTimes.svelte @@ -0,0 +1,111 @@ + + +
+
+

Prayer Times

+ +
+

Calculated from your location — not looked up from a directory.

+ + {#if status === 'idle'} + + {:else if status === 'locating'} +

Getting your location…

+ {:else if status === 'error'} +

{errorMsg}

+ + {:else if status === 'ready' && times} +
+ + +
+
+ {#each Object.entries(NAMES) as [key, label]} +
+ {label} + {times[key] ? formatClock(times[key]) : '—'} +
+ {/each} +
+ + {/if} + + +
+ + diff --git a/src/lib/QiblaFinder.svelte b/src/lib/QiblaFinder.svelte new file mode 100644 index 0000000..061bdbc --- /dev/null +++ b/src/lib/QiblaFinder.svelte @@ -0,0 +1,133 @@ + + +
+
+

Qibla

+ +
+

Great-circle bearing to the Kaaba, calculated from your device's location.

+ + {#if status === 'idle'} + + {:else if status === 'locating'} +

Getting your location…

+ {:else if status === 'error'} +

{errorMsg}

+ + {:else if status === 'ready'} +
+
+
+
+
+ N +
+
+ {Math.round(bearing)}° + from true north +
+ {#if !compassSupported} +

No live compass sensor detected — hold a compass app or physical compass and align it to {Math.round(bearing)}°.

+ {/if} +

~{distanceKm?.toLocaleString()} km to Makkah

+
+ + {/if} + + +
+ + diff --git a/src/lib/QuranReader.svelte b/src/lib/QuranReader.svelte new file mode 100644 index 0000000..cd822a3 --- /dev/null +++ b/src/lib/QuranReader.svelte @@ -0,0 +1,133 @@ + + +
+
+

Quran

+ +
+ + {#if !selectedSurah} +

114 Surahs — tap one to read.

+ + {#if listStatus === 'loading'} +

Loading Surah list…

+ {:else if listStatus === 'error'} +

Could not reach the Quran text service. Check your connection and reload this tab.

+ {:else} +
+ {#each filteredSurahs as s (s.number)} + + {/each} +
+ {/if} + {:else} + +

{selectedSurah.englishName} — {selectedSurah.englishNameTranslation}

+ {#if readerStatus === 'loading'} +

Loading…

+ {:else if readerStatus === 'error'} +

Could not load this Surah. Check your connection and try again.

+ {:else} +
+ {#each ayahs as a (a.number)} +
+ {a.number} +

{a.arabic}

+

{a.translation}

+
+ {/each} +
+ {/if} + {/if} + + +
+ + diff --git a/src/lib/calc/prayerTimes.js b/src/lib/calc/prayerTimes.js new file mode 100644 index 0000000..a9222d7 --- /dev/null +++ b/src/lib/calc/prayerTimes.js @@ -0,0 +1,89 @@ +// Prayer time calculation — the standard astronomical method used across +// open-source Islamic prayer-time tools (single-pass solar position, MWL +// angles by default): computed entirely client-side from geolocation and +// the device's own timezone offset. No API, no key, no external service. +// Single-pass precision is approximate (within a few minutes) — see the +// Disclaimer in PrayerTimes.svelte; this matches the rest of the app's +// "manual entry / calculated, verify locally" philosophy. + +const D2R = Math.PI / 180; +const R2D = 180 / Math.PI; +const dsin = d => Math.sin(d * D2R); +const dcos = d => Math.cos(d * D2R); +const dtan = d => Math.tan(d * D2R); +const darcsin = x => Math.asin(x) * R2D; +const darccos = x => Math.acos(x) * R2D; +const darctan2 = (y, x) => Math.atan2(y, x) * R2D; +const fixAngle = a => a - 360 * Math.floor(a / 360); +const fixHour = h => h - 24 * Math.floor(h / 24); + +function julianDate(year, month, day) { + if (month <= 2) { year -= 1; month += 12; } + const A = Math.floor(year / 100); + const B = 2 - A + Math.floor(A / 4); + return Math.floor(365.25 * (year + 4716)) + Math.floor(30.6001 * (month + 1)) + day + B - 1524.5; +} + +function sunPosition(jd) { + const D = jd - 2451545.0; + const g = fixAngle(357.529 + 0.98560028 * D); + const q = fixAngle(280.459 + 0.98564736 * D); + const L = fixAngle(q + 1.915 * dsin(g) + 0.02 * dsin(2 * g)); + const e = 23.439 - 0.00000036 * D; + const RA = darctan2(dcos(e) * dsin(L), dcos(L)) / 15; + const eqt = q / 15 - fixHour(RA); + const decl = darcsin(dsin(e) * dsin(L)); + return { declination: decl, equation: eqt }; +} + +/** Hour angle (in hours from solar noon) at which the sun reaches `angle` degrees below the horizon. */ +function angleTime(angle, jd, lat, direction) { + const decl = sunPosition(jd).declination; + const ratio = (-dsin(angle) - dsin(decl) * dsin(lat)) / (dcos(decl) * dcos(lat)); + if (ratio > 1 || ratio < -1) return null; // sun never reaches this angle at this latitude/date (polar regions) + const t = darccos(ratio) / 15; + return direction === 'ccw' ? -t : t; +} + +function asrOffset(shadowFactor, jd, lat) { + const decl = sunPosition(jd).declination; + const altitude = darctan2(1, shadowFactor + dtan(Math.abs(lat - decl))); // arccot via atan2 + return angleTime(-altitude, jd, lat, 'cw'); +} + +/** + * @param {object} params + * latitude, longitude, date (JS Date), timezoneOffsetHours (device offset from UTC, e.g. -new Date().getTimezoneOffset()/60), + * fajrAngle (default 18, MWL), ishaAngle (default 17, MWL), asrShadowFactor (1 = Shafi'i/majority, 2 = Hanafi) + */ +export function calculatePrayerTimes({ latitude, longitude, date, timezoneOffsetHours, fajrAngle = 18, ishaAngle = 17, asrShadowFactor = 1 }) { + const jd = julianDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); + const eqt = sunPosition(jd).equation; + const noon = fixHour(12 - eqt); + const tzAdjust = timezoneOffsetHours - longitude / 15; + + const offsets = { + fajr: angleTime(fajrAngle, jd, latitude, 'ccw'), + sunrise: angleTime(0.833, jd, latitude, 'ccw'), + dhuhr: 0, + asr: asrOffset(asrShadowFactor, jd, latitude), + maghrib: angleTime(0.833, jd, latitude, 'cw'), + isha: angleTime(ishaAngle, jd, latitude, 'cw') + }; + + const result = {}; + for (const [name, offset] of Object.entries(offsets)) { + if (offset == null) { result[name] = null; continue; } + const t = fixHour(noon + offset + tzAdjust); + const h = Math.floor(t); + const m = Math.round((t - h) * 60); + result[name] = { hours: h, minutes: m === 60 ? 0 : m }; + } + return result; +} + +export function formatClock({ hours, minutes }) { + const h12 = hours % 12 === 0 ? 12 : hours % 12; + const ampm = hours < 12 ? 'AM' : 'PM'; + return `${h12}:${String(minutes).padStart(2, '0')} ${ampm}`; +} diff --git a/src/lib/db.js b/src/lib/db.js index e4f4cc9..ce54b14 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -40,8 +40,8 @@ export async function listTrustedContacts(familyId) { if (error) throw error; return data || []; } -export async function addTrustedContact(familyId, name, method) { - const { error } = await supabase.from('nf_trusted_contacts').insert({ family_id: familyId, name, method }); +export async function addTrustedContact(familyId, name, method, category, email) { + const { error } = await supabase.from('nf_trusted_contacts').insert({ family_id: familyId, name, method, category: category || 'family', email: email || null }); if (error) throw error; } export async function removeTrustedContact(id) { @@ -49,6 +49,91 @@ export async function removeTrustedContact(id) { if (error) throw error; } +// ── Khairat — per-member scheme membership + a per-family emergency fund. ── +export async function listKhairatMemberships(familyId) { + const { data, error } = await supabase.from('nf_khairat_memberships').select('*').eq('family_id', familyId).order('created_at'); + if (error) throw error; + return (data || []).map(k => ({ + id: k.id, memberId: k.member_id, schemeName: k.scheme_name, organization: k.organization, + membershipNumber: k.membership_number, contactPhone: k.contact_phone, contactEmail: k.contact_email, notes: k.notes + })); +} +export async function addKhairatMembership(familyId, memberId, createdBy, fields) { + const { error } = await supabase.from('nf_khairat_memberships').insert({ + family_id: familyId, member_id: memberId, created_by: createdBy, scheme_name: fields.schemeName, + organization: fields.organization, membership_number: fields.membershipNumber, + contact_phone: fields.contactPhone, contact_email: fields.contactEmail, notes: fields.notes + }); + if (error) throw error; +} +export async function removeKhairatMembership(id) { + const { error } = await supabase.from('nf_khairat_memberships').delete().eq('id', id); + if (error) throw error; +} + +export async function getEmergencyFund(familyId) { + const { data, error } = await supabase.from('nf_emergency_fund').select('*').eq('family_id', familyId).maybeSingle(); + if (error) throw error; + return data ? { targetAmount: data.target_amount, currentBalance: data.current_balance, notes: data.notes } : null; +} +export async function upsertEmergencyFund(familyId, fields) { + const { error } = await supabase.from('nf_emergency_fund').upsert({ + family_id: familyId, target_amount: fields.targetAmount ? Number(fields.targetAmount) : null, + current_balance: Number(fields.currentBalance) || 0, notes: fields.notes || null, updated_at: new Date().toISOString() + }, { onConflict: 'family_id' }); + if (error) throw error; +} + +/** Sends an emergency notice to trusted contacts in the given categories via the notify-emergency-contacts Edge Function. */ +export async function notifyEmergencyContacts(memberId, familyId, categories) { + const { data, error } = await supabase.functions.invoke('notify-emergency-contacts', { body: { memberId, familyId, categories } }); + if (error) throw error; + return data; +} + +// ── Neighbourhood announcement board — join-by-code community, independent +// of the family estate structure. ── +function genJoinCode() { + return Array.from({ length: 6 }, () => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'[Math.floor(Math.random() * 32)]).join(''); +} +export async function createNeighbourhood(name, userId) { + const joinCode = genJoinCode(); + const { data, error } = await supabase.from('nf_neighbourhoods').insert({ name, join_code: joinCode, created_by: userId }).select().single(); + if (error) throw error; + await supabase.from('nf_neighbourhood_members').insert({ neighbourhood_id: data.id, user_id: userId }); + return data; +} +export async function joinNeighbourhoodByCode(joinCode, userId, displayName) { + const { data: n, error: findErr } = await supabase.from('nf_neighbourhoods').select('*').eq('join_code', joinCode.toUpperCase().trim()).maybeSingle(); + if (findErr) throw findErr; + if (!n) throw new Error('No neighbourhood found with that code.'); + const { error } = await supabase.from('nf_neighbourhood_members').insert({ neighbourhood_id: n.id, user_id: userId, display_name: displayName || null }); + if (error) throw error; + return n; +} +export async function listMyNeighbourhoods(userId) { + const { data, error } = await supabase.from('nf_neighbourhood_members').select('neighbourhood_id, nf_neighbourhoods(id, name, join_code)').eq('user_id', userId); + if (error) throw error; + return (data || []).map(r => ({ id: r.neighbourhood_id, name: r.nf_neighbourhoods?.name, joinCode: r.nf_neighbourhoods?.join_code })); +} +export async function leaveNeighbourhood(neighbourhoodId, userId) { + const { error } = await supabase.from('nf_neighbourhood_members').delete().eq('neighbourhood_id', neighbourhoodId).eq('user_id', userId); + if (error) throw error; +} +export async function listNeighbourhoodPosts(neighbourhoodId) { + const { data, error } = await supabase.from('nf_neighbourhood_posts').select('*').eq('neighbourhood_id', neighbourhoodId).order('created_at', { ascending: false }); + if (error) throw error; + return data || []; +} +export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body) { + const { error } = await supabase.from('nf_neighbourhood_posts').insert({ neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body }); + if (error) throw error; +} +export async function removeNeighbourhoodPost(id) { + const { error } = await supabase.from('nf_neighbourhood_posts').delete().eq('id', id); + if (error) throw error; +} + // ── Hibah ── export async function listHibahGifts(familyId) { const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at'); diff --git a/src/lib/overpass.js b/src/lib/overpass.js new file mode 100644 index 0000000..c82f083 --- /dev/null +++ b/src/lib/overpass.js @@ -0,0 +1,59 @@ +// Nearby-place search via the public OpenStreetMap Overpass API — free, +// keyless, real community-sourced data. Deliberately never fabricates +// listings: an empty result means nothing was found nearby, shown as such. +const OVERPASS_URL = 'https://overpass-api.de/api/interpreter'; + +const QUERIES = { + mosque: tags => `node["amenity"="place_of_worship"]["religion"="muslim"](around:${tags.radius},${tags.lat},${tags.lon}); +way["amenity"="place_of_worship"]["religion"="muslim"](around:${tags.radius},${tags.lat},${tags.lon});`, + halal: tags => `node["diet:halal"="yes"](around:${tags.radius},${tags.lat},${tags.lon}); +way["diet:halal"="yes"](around:${tags.radius},${tags.lat},${tags.lon});`, + cemetery: tags => `node["landuse"="cemetery"](around:${tags.radius},${tags.lat},${tags.lon}); +way["landuse"="cemetery"](around:${tags.radius},${tags.lat},${tags.lon}); +node["amenity"="grave_yard"](around:${tags.radius},${tags.lat},${tags.lon});` +}; + +const LABELS = { mosque: 'Mosque / musalla', halal: 'Halal food', cemetery: 'Muslim cemetery' }; +export const CATEGORIES = Object.keys(QUERIES); +export function categoryLabel(cat) { return LABELS[cat] || cat; } + +function toRad(d) { return (d * Math.PI) / 180; } +function distanceKm(lat1, lon1, lat2, lon2) { + const R = 6371; + const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +/** Searches OpenStreetMap for real nearby places in the given category. Returns [] on no results, throws on network/API failure. */ +export async function searchNearby(category, lat, lon, radiusMeters = 5000) { + const q = QUERIES[category]; + if (!q) throw new Error(`Unknown category: ${category}`); + const query = `[out:json][timeout:20];(${q({ lat, lon, radius: radiusMeters })});out center 30;`; + const res = await fetch(OVERPASS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'data=' + encodeURIComponent(query) + }); + if (!res.ok) throw new Error(`Overpass API returned ${res.status}`); + const data = await res.json(); + return (data.elements || []) + .map(el => { + const elLat = el.lat ?? el.center?.lat; + const elLon = el.lon ?? el.center?.lon; + if (elLat == null || elLon == null) return null; + return { + id: el.id, + name: el.tags?.name || categoryLabel(category), + lat: elLat, lon: elLon, + distanceKm: distanceKm(lat, lon, elLat, elLon), + address: [el.tags?.['addr:housenumber'], el.tags?.['addr:street'], el.tags?.['addr:city']].filter(Boolean).join(', ') + }; + }) + .filter(Boolean) + .sort((a, b) => a.distanceKm - b.distanceKm); +} + +export function directionsUrl(place) { + return `https://www.openstreetmap.org/directions?to=${place.lat}%2C${place.lon}`; +}