Prayer times (offline-first)
An Islamic prayer-times + Qibla app that computes everything locally with the adhan package, then optionally enriches with a declared-egress fetch to the Aladhan API. ~20 minutes.
This recipe builds the kind of app that should work in a tunnel: five
daily prayer times and a Qibla bearing, computed entirely on-device
from the car's location — no network needed. Then we add an optional
enhancement that fetches a whole month's calendar from a third-party
API, gated behind a single declared network origin, with a clean
fallback when the car is offline.
The pattern generalises: compute what you can locally; reach the network only for what you genuinely can't, and only for declared origins.
What you'll exercise
- Local prayer-time + Qibla math with the
adhannpm package (zero network). - The
location.readpermission +navigator.geolocationfor the car's coordinates. - An optional declared-egress
fetch()tohttps://api.aladhan.com, with offline fallback to the local computation.
1 — Scaffold and add adhan
pnpm dlx i99dash init prayer --template vanilla
cd prayer
pnpm install
pnpm add adhanadhan is a pure-math library — it ships no network code. Bundle it
into your app (e.g. with esbuild, --format=iife --target=es2019 for
broad WebView support).
2 — Manifest
The local-only version needs just the location permission. We add the
Aladhan origin to network now so the optional step in §5 works;
leave it out entirely if you only ever compute locally — then the
app reaches no third-party network at all.
{
"id": "prayer",
"name": { "en": "Prayer Times & Qibla", "ar": "مواقيت الصلاة والقبلة" },
"icon": "./icon.svg",
"url": "https://miniapps.i99dash.app/prayer/",
"version": "0.1.0",
"category": "lifestyle",
"safeWhileDriving": true,
"permissions": ["location.read"],
"network": ["https://api.aladhan.com"]
}3 — Read the car's location
getContext() gives you locale + the active car. For coordinates, use
the standard navigator.geolocation — the host gates it per-origin
against the location.read permission you declared, so it works inside
the head unit the same way it does in a browser preview (see the
Location guide). Fall back to Makkah (the
Kaaba) when no fix is available, so the app always renders something.
import { MiniAppClient } from 'i99dash';
const KAABA = { lat: 21.4225, lng: 39.8262 };
const client = MiniAppClient.fromWindow();
async function getCoords(): Promise<{ lat: number; lng: number }> {
if (typeof navigator === 'undefined' || !navigator.geolocation) return KAABA;
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: false,
timeout: 10_000,
maximumAge: 60_000,
}),
);
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
} catch {
// permission denied, timeout, or a broken WebView geolocation stack
return KAABA;
}
}4 — Compute prayer times + Qibla locally (no network)
This is the heart of the app, and it never touches the network. adhan
takes coordinates, a date, and a calculation method, and returns the
five prayer Dates. adhan.Qibla() returns the bearing to the Kaaba
in degrees from true north.
import * as adhan from 'adhan';
export interface DayTimes {
fajr: Date;
sunrise: Date;
dhuhr: Date;
asr: Date;
maghrib: Date;
isha: Date;
}
export function computeLocal(coords: { lat: number; lng: number }, date = new Date()) {
const acoords = new adhan.Coordinates(coords.lat, coords.lng);
// Pick a calculation method appropriate to the region. MuslimWorldLeague
// is a sensible default; expose this as a setting for the user later.
const params = adhan.CalculationMethod.MuslimWorldLeague();
params.madhab = adhan.Madhab.Shafi;
const t = new adhan.PrayerTimes(acoords, date, params);
const times: DayTimes = {
fajr: t.fajr,
sunrise: t.sunrise,
dhuhr: t.dhuhr,
asr: t.asr,
maghrib: t.maghrib,
isha: t.isha,
};
const qiblaBearing = adhan.Qibla(acoords); // degrees from true north
return { times, qiblaBearing };
}At this point you have a fully working, offline app: read coords,
computeLocal(), render the five times and a compass arrow. Ship it
without ever calling §5. Recompute when the day rolls over or the car
moves to a meaningfully different location.
5 — Optional: enrich with a monthly calendar (declared egress)
Local computation already gives you every day's times — but say you
want the provider's pre-formatted monthly calendar (e.g. to match a
printed timetable a user recognises, or to cross-check your method).
The Aladhan API returns a full
month in one call. Because it's a third-party origin, it must be in
manifest.network (we added it in §2), and we fall back to the local
computation when the car is offline.
const ALADHAN = 'https://api.aladhan.com';
/// Fetch the whole month's calendar from Aladhan. Returns null on any
/// failure (offline, CSP block, bad response) so the caller can fall
/// back to local compute — the app never depends on the network.
export async function fetchMonthlyCalendar(
coords: { lat: number; lng: number },
year: number,
month: number, // 1-12
): Promise<unknown | null> {
try {
const url =
`${ALADHAN}/v1/calendar/${year}/${month}` +
`?latitude=${coords.lat}&longitude=${coords.lng}&method=3`; // 3 = Muslim World League
const res = await fetch(url, { signal: AbortSignal.timeout(8_000) });
if (!res.ok) return null;
const body = (await res.json()) as { code?: number; data?: unknown };
return body.code === 200 ? (body.data ?? null) : null;
} catch {
// fetch() rejects with TypeError on a network failure OR a CSP block
// (origin missing from manifest.network). Either way: fall back.
return null;
}
}Wire it offline-first — local compute is the source of truth; the remote calendar is a bonus that degrades silently:
async function loadMonth(coords: { lat: number; lng: number }) {
const now = new Date();
// 1 — always have something: local compute, instantly, offline.
const today = computeLocal(coords, now);
renderToday(today);
// 2 — opportunistically enrich. If this fails we already rendered.
const remote = await fetchMonthlyCalendar(coords, now.getFullYear(), now.getMonth() + 1);
if (remote) renderMonthGrid(remote);
else markMonthGridUnavailable(); // "Showing today only — offline"
}Why this shape
- Offline-first is the right default for a prayer app. A driver in
a tunnel or a dead-zone still needs to know when Maghrib is. Local
adhanmath guarantees that; the network is strictly additive. - One declared origin, clearly scoped.
network: ["https://api.aladhan.com"]is the entire egress surface. A reviewer (and you) can see exactly what the app reaches. - The fetch is unauthenticated and that's fine here — Aladhan is a public API needing no key. Declared egress attaches no i99dash credentials, so this is the ideal kind of upstream: public, keyless, read-only. (Needs a secret? Don't embed it — front the upstream with your own service and declare that. See Calling an external API.)
Related
- Calling an external API — the declared-egress model in full.
- Call a third-party API — the minimal
fetch()pattern. MiniAppManifest— thenetworkandpermissionsfields.- Location guide — reading the car's position.