Add Prayer Reminder, Dhikr Counter, Qibla Finder, Halal Monitor updates

- Prayer reminder: /prayer page with 5 daily times, countdown, alAdhan API
- Dhikr counter: /dhikr page with 3 presets, custom counter, haptics, DB tracking
- Qibla finder: /qibla page with compass SVG, DeviceOrientation, bearing to Kaaba
- Halal Monitor: Leaflet + Google Places, 26 KL markers, geolocation, distance sort
- BottomNav: added clock icon for prayer
- Prisma: schema updates for DhikrSession, DhikrDay, DailyStreak, XpTransaction
- Geo utility module
- Weighted Traefik routing: Contabo 99% / Synology 1% cold standby
This commit is contained in:
root
2026-06-18 14:51:07 +02:00
parent ed34b186f9
commit 28be776c84
13 changed files with 2822 additions and 104 deletions
+92
View File
@@ -0,0 +1,92 @@
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
// ── Helpers ────────────────────────────────────────────────────────────────
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function getDhikrType(raw: string): string {
const allowed = ["subhanallah", "alhamdulillah", "allahuakbar", "custom"];
return allowed.includes(raw) ? raw : "custom";
}
// ── GET /api/dhikr/stats — Today's dhikr stats ────────────────────────────
export async function GET(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const today = todayStr();
let dayStats = await prisma.dhikrDay.findUnique({
where: { userId_date: { userId: jwt.userId, date: today } },
});
// Recent sessions (last 20)
const recentSessions = await prisma.dhikrSession.findMany({
where: { userId: jwt.userId },
orderBy: { createdAt: "desc" },
take: 20,
select: { id: true, type: true, count: true, createdAt: true },
});
if (!dayStats) {
dayStats = await prisma.dhikrDay.create({
data: { userId: jwt.userId, date: today },
});
}
return NextResponse.json({ dayStats, recentSessions });
}
// ── POST /api/dhikr/stats — Log a dhikr session ────────────────────────────
export async function POST(request: Request) {
const jwt = await requireAuth(request);
if (!jwt) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { type?: string; count?: number; target?: number; completed?: boolean };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const type = getDhikrType(body.type || "custom");
const count = Math.min(Math.max(body.count || 1, 1), 9999);
const target = body.target || 33;
const completed = body.completed !== false;
const today = todayStr();
// Create session record
const session = await prisma.dhikrSession.create({
data: { userId: jwt.userId, type, count, target, completed },
});
// Upsert today's aggregate
const dayStats = await prisma.dhikrDay.upsert({
where: { userId_date: { userId: jwt.userId, date: today } },
create: {
userId: jwt.userId,
date: today,
[type]: count,
totalCount: count,
sessionCount: 1,
},
update: {
totalCount: { increment: count },
sessionCount: { increment: 1 },
[type]: { increment: count },
},
});
return NextResponse.json({ session, dayStats });
}
+16
View File
@@ -9,11 +9,27 @@ const MOCK_PLACES: Record<string, any> = {
"mosque-3": { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
"mosque-4": { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
"mosque-5": { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
"mosque-6": { id: "mosque-6", name: "Masjid India", address: "Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1462, lng: 101.6962, rating: 4.4, type: "mosque", halal_certified: true },
"mosque-7": { id: "mosque-7", name: "Masjid Saidina Abu Bakar As Siddiq", address: "Jalan Bangsar, 59100 Kuala Lumpur", lat: 3.1277, lng: 101.6778, rating: 4.6, type: "mosque", halal_certified: true },
"mosque-8": { id: "mosque-8", name: "Masjid Al-Akram", address: "Kompleks PKNS, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1112, lng: 101.6668, rating: 4.3, type: "mosque", halal_certified: true },
"mosque-9": { id: "mosque-9", name: "Masjid Jamek Pantai Dalam", address: "Lorong Bukit Pantai, 59100 Kuala Lumpur", lat: 3.1033, lng: 101.6688, rating: 4.2, type: "mosque", halal_certified: true },
"mosque-10": { id: "mosque-10", name: "Masjid Al-Hasanah", address: "Jalan Perak, 50450 Kuala Lumpur", lat: 3.1649, lng: 101.7088, rating: 4.5, type: "mosque", halal_certified: true },
"rest-1": { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
"rest-2": { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-3": { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
"rest-4": { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
"rest-5": { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
"rest-6": { id: "rest-6", name: "Sultan Restaurant", address: "No. 88, Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1458, lng: 101.7138, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-7": { id: "rest-7", name: "Din Tai Fung (Pavilion)", address: "Lot 6.01, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1562, lng: 101.7133, rating: 4.5, type: "restaurant", halal_certified: true },
"rest-8": { id: "rest-8", name: "Beirut Grill", address: "G-01, Wisma Limi, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1552, lng: 101.7139, rating: 4.4, type: "restaurant", halal_certified: true },
"rest-9": { id: "rest-9", name: "Rebung (Istana Budaya)", address: "Jalan Tembi, 50650 Kuala Lumpur", lat: 3.1519, lng: 101.7145, rating: 4.6, type: "restaurant", halal_certified: true },
"rest-10": { id: "rest-10", name: "Nasi Lemak Bumbung", address: "Jalan Telawi 2, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1347, lng: 101.6789, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-11": { id: "rest-11", name: "Yut Kee", address: "35, Jalan Dang Wangi, 50100 Kuala Lumpur", lat: 3.1507, lng: 101.6977, rating: 4.5, type: "restaurant", halal_certified: true },
"cafe-1": { id: "cafe-1", name: "Brew & Bread", address: "Lot 6.12, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1559, lng: 101.7149, rating: 4.2, type: "cafe", halal_certified: true },
"cafe-2": { id: "cafe-2", name: "Artisan Coffee", address: "2, Jalan Telawi 5, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1333, lng: 101.6797, rating: 4.3, type: "cafe", halal_certified: true },
"cafe-3": { id: "cafe-3", name: "The Gajah Coffee", address: "32, Jalan Telawi 4, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1299, lng: 101.6709, rating: 4.4, type: "cafe", halal_certified: true },
"cafe-4": { id: "cafe-4", name: "VCR Cafe", address: "26, Jalan Kamunting, 50300 Kuala Lumpur", lat: 3.1534, lng: 101.7129, rating: 4.5, type: "cafe", halal_certified: true },
"cafe-5": { id: "cafe-5", name: "Butter + Beans", address: "43, Jalan Medan Setia 1, Bukit Damansara, 50490 Kuala Lumpur", lat: 3.1416, lng: 101.6995, rating: 4.3, type: "cafe", halal_certified: true },
};
export async function GET(request: NextRequest) {
+56 -5
View File
@@ -1,19 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { haversineDistance } from "@/lib/geo";
const MOCK_PLACES = [
// Mosques
interface Place {
id: string;
name: string;
address: string;
lat: number;
lng: number;
rating: number;
type: "mosque" | "restaurant" | "cafe";
halal_certified: boolean;
distance?: number;
}
const MOCK_PLACES: Place[] = [
// ── Mosques ───────────────────────────────────────────
{ id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true },
{ id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true },
{ id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
{ id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
{ id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
{ id: "mosque-6", name: "Masjid India", address: "Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1462, lng: 101.6962, rating: 4.4, type: "mosque", halal_certified: true },
{ id: "mosque-7", name: "Masjid Saidina Abu Bakar As Siddiq", address: "Jalan Bangsar, 59100 Kuala Lumpur", lat: 3.1277, lng: 101.6778, rating: 4.6, type: "mosque", halal_certified: true },
{ id: "mosque-8", name: "Masjid Al-Akram", address: "Kompleks PKNS, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1112, lng: 101.6668, rating: 4.3, type: "mosque", halal_certified: true },
{ id: "mosque-9", name: "Masjid Jamek Pantai Dalam", address: "Lorong Bukit Pantai, 59100 Kuala Lumpur", lat: 3.1033, lng: 101.6688, rating: 4.2, type: "mosque", halal_certified: true },
{ id: "mosque-10", name: "Masjid Al-Hasanah", address: "Jalan Perak, 50450 Kuala Lumpur", lat: 3.1649, lng: 101.7088, rating: 4.5, type: "mosque", halal_certified: true },
// Restaurants
// ── Restaurants ───────────────────────────────────────
{ id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
{ id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
{ id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
{ id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
{ id: "rest-6", name: "Sultan Restaurant", address: "No. 88, Jalan Masjid India, 50100 Kuala Lumpur", lat: 3.1458, lng: 101.7138, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-7", name: "Din Tai Fung (Pavilion)", address: "Lot 6.01, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1562, lng: 101.7133, rating: 4.5, type: "restaurant", halal_certified: true },
{ id: "rest-8", name: "Beirut Grill", address: "G-01, Wisma Limi, Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1552, lng: 101.7139, rating: 4.4, type: "restaurant", halal_certified: true },
{ id: "rest-9", name: "Rebung (Istana Budaya)", address: "Jalan Tembi, 50650 Kuala Lumpur", lat: 3.1519, lng: 101.7145, rating: 4.6, type: "restaurant", halal_certified: true },
{ id: "rest-10", name: "Nasi Lemak Bumbung", address: "Jalan Telawi 2, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1347, lng: 101.6789, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-11", name: "Yut Kee", address: "35, Jalan Dang Wangi, 50100 Kuala Lumpur", lat: 3.1507, lng: 101.6977, rating: 4.5, type: "restaurant", halal_certified: true },
// ── Cafes ─────────────────────────────────────────────
{ id: "cafe-1", name: "Brew & Bread", address: "Lot 6.12, Pavilion KL, 168 Jalan Bukit Bintang, 55100 Kuala Lumpur", lat: 3.1559, lng: 101.7149, rating: 4.2, type: "cafe", halal_certified: true },
{ id: "cafe-2", name: "Artisan Coffee", address: "2, Jalan Telawi 5, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1333, lng: 101.6797, rating: 4.3, type: "cafe", halal_certified: true },
{ id: "cafe-3", name: "The Gajah Coffee", address: "32, Jalan Telawi 4, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1299, lng: 101.6709, rating: 4.4, type: "cafe", halal_certified: true },
{ id: "cafe-4", name: "VCR Cafe", address: "26, Jalan Kamunting, 50300 Kuala Lumpur", lat: 3.1534, lng: 101.7129, rating: 4.5, type: "cafe", halal_certified: true },
{ id: "cafe-5", name: "Butter + Beans", address: "43, Jalan Medan Setia 1, Bukit Damansara, 50490 Kuala Lumpur", lat: 3.1416, lng: 101.6995, rating: 4.3, type: "cafe", halal_certified: true },
];
export async function GET(request: NextRequest) {
@@ -21,27 +52,47 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.toLowerCase();
const type = searchParams.get("type")?.toLowerCase();
const latParam = searchParams.get("lat");
const lngParam = searchParams.get("lng");
let filtered = [...MOCK_PLACES];
// Filter by type if provided and valid
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
filtered = filtered.filter((p) => p.type === type);
}
// Filter by search query (name or address)
if (q) {
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.address.toLowerCase().includes(q)
p.address.toLowerCase().includes(q),
);
}
// If lat/lng provided, calculate distance and sort by nearest
if (latParam && lngParam) {
const userLat = parseFloat(latParam);
const userLng = parseFloat(lngParam);
if (!isNaN(userLat) && !isNaN(userLng)) {
filtered = filtered.map((p) => ({
...p,
distance: haversineDistance(userLat, userLng, p.lat, p.lng),
}));
// Sort by distance ascending (nearest first)
filtered.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
}
}
return NextResponse.json({ places: filtered });
} catch (error) {
console.error("GET /api/halal/places error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
{ status: 500 },
);
}
}
+357
View File
@@ -0,0 +1,357 @@
import { NextRequest } from "next/server";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
// ── Timezone abbreviation mapping ──────────────────────────────────────────
const TZ_ABBREVIATIONS: Record<string, string> = {
"Asia/Kuala_Lumpur": "MYT",
"Asia/Singapore": "SGT",
"Asia/Jakarta": "WIB",
"Asia/Makassar": "WITA",
"Asia/Jayapura": "WIT",
"Asia/Bangkok": "ICT",
"Asia/Ho_Chi_Minh": "ICT",
"Asia/Phnom_Penh": "ICT",
"Asia/Vientiane": "ICT",
"Asia/Yangon": "MMT",
"Asia/Dhaka": "BST",
"Asia/Kolkata": "IST",
"Asia/Karachi": "PKT",
"Asia/Dubai": "GST",
"Asia/Riyadh": "AST",
"Asia/Baghdad": "AST",
"Asia/Tehran": "IRST",
"Asia/Kabul": "AFT",
"Asia/Tashkent": "UZT",
"Europe/London": "GMT",
"Europe/Istanbul": "TRT",
"Europe/Berlin": "CET",
"Europe/Paris": "CET",
"America/New_York": "EST",
"America/Chicago": "CST",
"America/Denver": "MST",
"America/Los_Angeles": "PST",
"America/Toronto": "EST",
"Africa/Cairo": "EET",
"Africa/Johannesburg": "SAST",
"Australia/Sydney": "AEDT",
"Australia/Perth": "AWST",
"Pacific/Auckland": "NZDT",
};
function getTimezoneAbbr(timezone: string): string {
// Direct lookup first
if (TZ_ABBREVIATIONS[timezone]) return TZ_ABBREVIATIONS[timezone];
// Try to extract abbreviation dynamically from the IANA name
// e.g., "America/New_York" -> "ET", "Asia/Kuala_Lumpur" -> "MYT"
const parts = timezone.split("/");
if (parts.length >= 2) {
const region = parts[0];
const city = parts[parts.length - 1].replace(/_/g, " ");
// Common known abbreviations
const known: Record<string, string> = {
London: "GMT",
Dublin: "GMT",
Lisbon: "WET",
Berlin: "CET",
Paris: "CET",
Rome: "CET",
Madrid: "CET",
Amsterdam: "CET",
Brussels: "CET",
Vienna: "CET",
Stockholm: "CET",
Oslo: "CET",
Copenhagen: "CET",
Warsaw: "CET",
Budapest: "CET",
Prague: "CET",
Athens: "EET",
Helsinki: "EET",
Bucharest: "EET",
Sofia: "EET",
Moscow: "MSK",
Istanbul: "TRT",
};
if (known[city]) return known[city];
}
// For Asia timezones, try to use country code from city name
const countryFromCity: Record<string, string> = {
"Kuala Lumpur": "MYT",
"Singapore": "SGT",
"Jakarta": "WIB",
"Tokyo": "JST",
"Seoul": "KST",
"Hong_Kong": "HKT",
"Taipei": "CST",
"Beijing": "CST",
"Shanghai": "CST",
"Manila": "PHT",
"Bangkok": "ICT",
"Hanoi": "ICT",
"New Delhi": "IST",
"Colombo": "IST",
"Kathmandu": "NPT",
"Dhaka": "BST",
"Islamabad": "PKT",
};
const cityKey = parts[parts.length - 1];
if (countryFromCity[cityKey]) return countryFromCity[cityKey];
// Fallback: return the timezone itself
return timezone;
}
// ── Cache helpers ──────────────────────────────────────────────────────────
const CACHE_DIR = "/tmp/prayer-cache";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
function cacheKey(city: string, country: string): string {
return `${city.toLowerCase().replace(/\s+/g, "-")}-${country.toLowerCase().replace(/\s+/g, "-")}`;
}
function cachePath(key: string): string {
return join(CACHE_DIR, `${key}.json`);
}
interface CacheEntry {
cachedAt: number;
data: AlAdhanResponse;
}
interface AlAdhanTimings {
Fajr: string;
Sunrise: string;
Dhuhr: string;
Asr: string;
Sunset: string;
Maghrib: string;
Isha: string;
Imsak: string;
Midnight: string;
Firstthird: string;
Lastthird: string;
}
interface AlAdhanDateInfo {
readable: string;
timestamp: string;
gregorian: {
date: string;
format: string;
day: string;
weekday: { en: string };
month: { number: number; en: string };
year: string;
designation: { abbreviated: string; expanded: string };
};
hijri: {
date: string;
format: string;
day: string;
weekday: { en: string; ar: string };
month: { number: number; en: string; ar: string };
year: string;
designation: { abbreviated: string; expanded: string };
holidays: string[];
};
}
interface AlAdhanMeta {
latitude: number;
longitude: number;
timezone: string;
method: { id: number; name: string };
latitudeAdjustment: string;
midnightMode: string;
school: string;
offset: Record<string, number>;
}
interface AlAdhanResponse {
code: number;
status: string;
data: {
timings: AlAdhanTimings;
date: AlAdhanDateInfo;
meta: AlAdhanMeta;
};
}
interface PrayerTimings {
Fajr: string;
Sunrise?: string;
Dhuhr: string;
Asr: string;
Sunset?: string;
Maghrib: string;
Isha: string;
[key: string]: string | undefined;
}
interface PrayerResponse {
timings: Record<string, string>;
date: string;
hijri: string;
city: string;
country: string;
timezone: string;
}
// ── Fallback defaults ──────────────────────────────────────────────────────
function getFallbackResponse(city: string, country: string): PrayerResponse {
const timezone = city === "Kuala Lumpur" && country === "Malaysia"
? "Asia/Kuala_Lumpur"
: `Asia/${city.replace(/\s/g, "_")}`;
const tzAbbr = getTimezoneAbbr(timezone);
return {
timings: {
Fajr: `05:42 (${tzAbbr})`,
Dhuhr: `13:15 (${tzAbbr})`,
Asr: `16:30 (${tzAbbr})`,
Maghrib: `19:22 (${tzAbbr})`,
Isha: `20:39 (${tzAbbr})`,
},
date: new Date().toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
}),
hijri: "—",
city,
country,
timezone,
};
}
// ── Main handler ───────────────────────────────────────────────────────────
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const city = searchParams.get("city") || "Kuala Lumpur";
const country = searchParams.get("country") || "Malaysia";
const key = cacheKey(city, country);
const cPath = cachePath(key);
// 1. Try reading from cache
try {
if (existsSync(cPath)) {
const raw = await readFile(cPath, "utf-8");
const entry: CacheEntry = JSON.parse(raw);
const age = Date.now() - entry.cachedAt;
// If cache is still fresh, return it
if (age < CACHE_TTL_MS) {
return Response.json(formatResponse(entry.data, city, country));
}
// If cache is stale but we have it, we'll try to refresh but fall back to stale
try {
const fresh = await fetchAlAdhan(city, country);
await saveCache(cPath, fresh);
return Response.json(formatResponse(fresh, city, country));
} catch {
// API failed; return stale cache
return Response.json(formatResponse(entry.data, city, country));
}
}
} catch {
// Cache read error — proceed to fetch fresh
}
// 2. No cache — fetch from alAdhan
try {
const data = await fetchAlAdhan(city, country);
await saveCache(cPath, data);
return Response.json(formatResponse(data, city, country));
} catch (error) {
console.error("Prayer times API error:", error);
// 3. Try stale cache even if we already checked (race condition guard)
try {
if (existsSync(cPath)) {
const raw = await readFile(cPath, "utf-8");
const entry: CacheEntry = JSON.parse(raw);
return Response.json(formatResponse(entry.data, city, country));
}
} catch {
// ignore
}
// 4. Last resort: fallback defaults
return Response.json(getFallbackResponse(city, country));
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
async function fetchAlAdhan(
city: string,
country: string
): Promise<AlAdhanResponse> {
const url = `https://api.aladhan.com/v1/timingsByCity?city=${encodeURIComponent(city)}&country=${encodeURIComponent(country)}&method=2&adjustment=1`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
next: { revalidate: 3600 },
});
if (!res.ok) {
throw new Error(`alAdhan API returned ${res.status}: ${await res.text()}`);
}
const json: AlAdhanResponse = await res.json();
if (json.code !== 200 || json.status !== "OK") {
throw new Error(`alAdhan API error: ${json.status} (code ${json.code})`);
}
return json;
}
async function saveCache(path: string, data: AlAdhanResponse): Promise<void> {
try {
await mkdir(CACHE_DIR, { recursive: true });
const entry: CacheEntry = { cachedAt: Date.now(), data };
await writeFile(path, JSON.stringify(entry), "utf-8");
} catch (err) {
console.error("Failed to write prayer cache:", err);
}
}
function formatResponse(
apiData: AlAdhanResponse,
city: string,
country: string
): PrayerResponse {
const { timings, date, meta } = apiData.data;
const tzAbbr = getTimezoneAbbr(meta.timezone);
// The alAdhan API returns times like "05:42 (MST)" already if adjustment is set,
// but we'll append our own timezone label to be safe
const prayerOrder = ["Fajr", "Sunrise", "Dhuhr", "Asr", "Sunset", "Maghrib", "Isha", "Imsak", "Midnight"];
const formattedTimings: Record<string, string> = {};
for (const prayer of prayerOrder) {
const time = (timings as unknown as Record<string, string>)[prayer];
if (time) {
// Strip any existing timezone label in parentheses and re-add ours
const cleanTime = time.replace(/\s*\(.*?\)\s*/g, "").trim();
formattedTimings[prayer] = `${cleanTime} (${tzAbbr})`;
}
}
return {
timings: formattedTimings,
date: date.readable,
hijri: `${date.hijri.day} ${date.hijri.month.en} ${date.hijri.year}`,
city,
country,
timezone: meta.timezone,
};
}