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,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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user