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
+133
View File
@@ -0,0 +1,133 @@
<script>
// Qibla direction — pure client-side great-circle bearing calculation from
// the device's GPS to the Kaaba. No API, no key, no external service:
// this is closed-form spherical trigonometry, not a lookup.
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const KAABA_LAT = 21.4225;
const KAABA_LON = 39.8262;
let status = $state('idle'); // idle | locating | ready | error
let errorMsg = $state('');
let bearing = $state(null); // degrees from true north, 0-360
let distanceKm = $state(null);
let compassHeading = $state(null); // device heading, if sensor available
let compassSupported = $state(false);
function toRad(deg) { return (deg * Math.PI) / 180; }
function toDeg(rad) { return (rad * 180) / Math.PI; }
function computeQibla(lat, lon) {
const phi1 = toRad(lat), phi2 = toRad(KAABA_LAT);
const dLambda = toRad(KAABA_LON - lon);
const y = Math.sin(dLambda) * Math.cos(phi2);
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda);
let theta = toDeg(Math.atan2(y, x));
bearing = (theta + 360) % 360;
// Haversine distance
const R = 6371;
const dPhi = toRad(KAABA_LAT - lat);
const a = Math.sin(dPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) ** 2;
distanceKm = Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
}
function handleOrientation(e) {
const heading = e.webkitCompassHeading ?? (e.absolute && e.alpha != null ? 360 - e.alpha : null);
if (heading != null) { compassHeading = heading; compassSupported = true; }
}
async function findQibla() {
status = 'locating';
errorMsg = '';
if (!navigator.geolocation) {
status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.';
return;
}
navigator.geolocation.getCurrentPosition(
async (pos) => {
computeQibla(pos.coords.latitude, pos.coords.longitude);
status = 'ready';
// Compass heading requires an explicit permission prompt on iOS 13+,
// which must be triggered by this same user gesture.
if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const perm = await DeviceOrientationEvent.requestPermission();
if (perm === 'granted') window.addEventListener('deviceorientation', handleOrientation, true);
} catch { /* permission denied or unsupported — static bearing still shown */ }
} else if (typeof DeviceOrientationEvent !== 'undefined') {
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
window.addEventListener('deviceorientation', handleOrientation, true);
}
},
(err) => { status = 'error'; errorMsg = err.message || 'Could not get your location.'; },
{ enableHighAccuracy: true, timeout: 10000 }
);
}
const needleRotation = $derived(compassSupported && compassHeading != null && bearing != null ? bearing - compassHeading : bearing);
</script>
<div class="module">
<div class="module-header">
<h2>Qibla</h2>
<InfoPanel
title="Qibla"
what="The direction to face for prayer, calculated as the great-circle bearing from your current location to the Kaaba in Makkah — computed on your device, not looked up from a database."
how="Tap to find your direction. If your device has a compass sensor and grants permission, the arrow rotates live as you turn; otherwise you'll see the bearing in degrees from true north to align with a separate compass."
fields={[]}
/>
</div>
<p class="sub">Great-circle bearing to the Kaaba, calculated from your device's location.</p>
{#if status === 'idle'}
<button class="btn-primary" onclick={findQibla}>Find Qibla direction</button>
{:else if status === 'locating'}
<p class="status-text">Getting your location…</p>
{:else if status === 'error'}
<p class="error-text">{errorMsg}</p>
<button class="btn-primary" onclick={findQibla}>Try again</button>
{:else if status === 'ready'}
<div class="compass-card">
<div class="compass-rose">
<div class="needle" style="transform: rotate({needleRotation}deg)">
<div class="needle-tip"></div>
</div>
<span class="compass-n">N</span>
</div>
<div class="bearing-readout">
<strong>{Math.round(bearing)}°</strong>
<span>from true north</span>
</div>
{#if !compassSupported}
<p class="static-note">No live compass sensor detected — hold a compass app or physical compass and align it to {Math.round(bearing)}°.</p>
{/if}
<p class="distance-note">~{distanceKm?.toLocaleString()} km to Makkah</p>
</div>
<button class="btn-secondary" onclick={findQibla}>Refresh location</button>
{/if}
<Disclaimer text="A calculated bearing, not a certified qibla marker. Verify against a known qibla direction (e.g. your local mosque) where precision matters." />
</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; }
.btn-primary { width: 100%; padding: 14px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 14px; }
.status-text { font-size: 13px; color: #8A8478; text-align: center; padding: 30px 0; }
.error-text { font-size: 13px; color: #EF4444; margin-bottom: 12px; }
.compass-card { display: flex; flex-direction: column; align-items: center; padding: 20px; background: rgba(255,255,255,0.03); border-radius: 16px; }
.compass-rose { position: relative; width: 200px; height: 200px; border-radius: 50%; border: 2px solid rgba(201,168,76,0.3); display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.needle { position: absolute; width: 4px; height: 90px; background: linear-gradient(#C9A84C, transparent); top: 10px; left: 50%; margin-left: -2px; transform-origin: bottom center; transition: transform 0.2s ease-out; }
.needle-tip { position: absolute; top: -14px; left: -9px; font-size: 20px; color: #C9A84C; }
.compass-n { position: absolute; top: 8px; font-size: 11px; color: #8A8478; }
.bearing-readout { display: flex; flex-direction: column; align-items: center; margin-bottom: 8px; }
.bearing-readout strong { font-family: 'DM Serif Display', serif; font-size: 32px; color: #C9A84C; }
.bearing-readout span { font-size: 11px; color: #8A8478; }
.static-note { font-size: 12px; color: #B8B2A6; text-align: center; margin-bottom: 8px; line-height: 1.5; }
.distance-note { font-size: 11px; color: #8A8478; }
</style>