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:
@@ -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>
|
||||
Reference in New Issue
Block a user