|
|
const app = () => ({
|
|
|
futureTimes: [],
|
|
|
selectedLocation: Alpine.$persist(null),
|
|
|
lastUpdated: Alpine.$persist(null),
|
|
|
searchQuery: "",
|
|
|
searchResults: [],
|
|
|
searchOpen: false,
|
|
|
searchLoading: false,
|
|
|
searchError: "",
|
|
|
searchDebounceTimer: null,
|
|
|
supportedSalathLangs: ["tr", "de", "en", "ar"],
|
|
|
userMinutes: 0,
|
|
|
now: new Date(),
|
|
|
debug: hasDebugHashFlag(),
|
|
|
|
|
|
async init() {
|
|
|
const urlCoords = getURLCoords();
|
|
|
if (urlCoords) {
|
|
|
await this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
|
|
|
} else if (this.selectedLocation) {
|
|
|
this.searchQuery = this.formatLocationLabel(this.selectedLocation);
|
|
|
await this.refreshPrayerTimes();
|
|
|
}
|
|
|
|
|
|
setInterval(() => {
|
|
|
this.now = new Date();
|
|
|
}, 500);
|
|
|
|
|
|
if (!urlCoords) {
|
|
|
try {
|
|
|
const coords = await getUserLocation();
|
|
|
await this.selectNearestLocation(coords.latitude, coords.longitude, {updateURL: true});
|
|
|
} catch (_error) {
|
|
|
// Ignore geolocation errors and rely on manual search.
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
onHash() {
|
|
|
this.debug = hasDebugHashFlag();
|
|
|
|
|
|
const urlCoords = getURLCoords();
|
|
|
if (urlCoords) {
|
|
|
this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
|
|
|
}
|
|
|
},
|
|
|
|
|
|
onSearchInput() {
|
|
|
this.searchError = "";
|
|
|
if (this.searchDebounceTimer) {
|
|
|
clearTimeout(this.searchDebounceTimer);
|
|
|
}
|
|
|
|
|
|
const query = this.searchQuery.trim();
|
|
|
if (query === "") {
|
|
|
this.searchResults = [];
|
|
|
this.searchOpen = false;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
this.searchDebounceTimer = setTimeout(() => {
|
|
|
this.searchLocations(query);
|
|
|
}, 250);
|
|
|
},
|
|
|
|
|
|
async searchLocations(query) {
|
|
|
this.searchLoading = true;
|
|
|
this.searchOpen = true;
|
|
|
try {
|
|
|
const response = await fetchJSON(`/api/v1/diyanet/location?query=${encodeURIComponent(query)}`);
|
|
|
this.searchResults = response.locations ?? [];
|
|
|
} catch (_error) {
|
|
|
this.searchResults = [];
|
|
|
this.searchError = "Failed to search locations.";
|
|
|
} finally {
|
|
|
this.searchLoading = false;
|
|
|
}
|
|
|
},
|
|
|
|
|
|
async selectNearestLocation(latitude, longitude, {updateURL = false} = {}) {
|
|
|
const response = await fetchJSON(`/api/v1/diyanet/location?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
|
|
|
const first = (response.locations ?? [])[0];
|
|
|
if (!first) {
|
|
|
return;
|
|
|
}
|
|
|
await this.selectLocation(first, {updateURL});
|
|
|
},
|
|
|
|
|
|
async selectLocation(location, {updateURL = true} = {}) {
|
|
|
this.selectedLocation = location;
|
|
|
this.searchQuery = this.formatLocationLabel(location);
|
|
|
this.searchOpen = false;
|
|
|
this.searchResults = [];
|
|
|
if (updateURL) {
|
|
|
setURLCoords(location.latitude, location.longitude);
|
|
|
}
|
|
|
await this.refreshPrayerTimes();
|
|
|
},
|
|
|
|
|
|
formatLocationLabel(location) {
|
|
|
if (!location) {
|
|
|
return "";
|
|
|
}
|
|
|
const country = (location.country_code || "").trim();
|
|
|
const name = (location.name || "").trim();
|
|
|
const asciiName = (location.ascii_name || "").trim();
|
|
|
if (name && asciiName && name !== asciiName) {
|
|
|
return `${name} (${asciiName})${country ? ` - ${country}` : ""}`;
|
|
|
}
|
|
|
if (name) {
|
|
|
return `${name}${country ? ` - ${country}` : ""}`;
|
|
|
}
|
|
|
return `${asciiName}${country ? ` - ${country}` : ""}`;
|
|
|
},
|
|
|
|
|
|
async refreshPrayerTimes() {
|
|
|
if (!this.selectedLocation) {
|
|
|
this.futureTimes = [];
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const latitude = this.selectedLocation.latitude;
|
|
|
const longitude = this.selectedLocation.longitude;
|
|
|
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
|
|
|
this.futureTimes = response.prayertimes ?? [];
|
|
|
this.lastUpdated = new Date().toISOString();
|
|
|
},
|
|
|
|
|
|
get userNow() {
|
|
|
if (!this.debug) {
|
|
|
return this.now;
|
|
|
}
|
|
|
|
|
|
const d = new Date();
|
|
|
d.setHours(0, this.userMinutes, 0, 0);
|
|
|
return d;
|
|
|
},
|
|
|
|
|
|
get userTime() {
|
|
|
return formatTime(this.userNow);
|
|
|
},
|
|
|
|
|
|
get todayTimes() {
|
|
|
if (this.futureTimes.length === 0) {
|
|
|
return null;
|
|
|
}
|
|
|
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
|
|
},
|
|
|
|
|
|
get preferredSalathLangs() {
|
|
|
const browserLanguages = (navigator.languages && navigator.languages.length > 0)
|
|
|
? navigator.languages
|
|
|
: [navigator.language];
|
|
|
|
|
|
const preferred = browserLanguages
|
|
|
.filter(Boolean)
|
|
|
.map((tag) => normalizeLanguageTag(tag))
|
|
|
.filter((lang) => this.supportedSalathLangs.includes(lang));
|
|
|
|
|
|
return Array.from(new Set([...preferred, ...this.supportedSalathLangs]));
|
|
|
},
|
|
|
|
|
|
salathNameDisplay(salath) {
|
|
|
const names = this.preferredSalathLangs.map((lang) => ({
|
|
|
lang,
|
|
|
text: PrayerTimes.translations[salath]?.[lang] ?? salath
|
|
|
}));
|
|
|
|
|
|
return {
|
|
|
primary: names[0],
|
|
|
secondary: names.slice(1)
|
|
|
};
|
|
|
}
|
|
|
});
|
|
|
|
|
|
class PrayerTimes {
|
|
|
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
|
|
static translations = {
|
|
|
fajr: {
|
|
|
tr: "İmsak",
|
|
|
en: "Fajr",
|
|
|
de: "Fruehgebet",
|
|
|
ar: "صلاة الفجر"
|
|
|
},
|
|
|
sunrise: {
|
|
|
tr: "Güneş",
|
|
|
en: "Sunrise",
|
|
|
de: "Sonnenaufgang",
|
|
|
ar: "الشروق"
|
|
|
},
|
|
|
dhuhr: {
|
|
|
tr: "Öğle",
|
|
|
en: "Dhuhr",
|
|
|
de: "Mittagsgebet",
|
|
|
ar: "صلاة الظهر"
|
|
|
},
|
|
|
asr: {
|
|
|
tr: "İkindi",
|
|
|
en: "Asr",
|
|
|
de: "Nachmittagsgebet",
|
|
|
ar: "صلاة العصر"
|
|
|
},
|
|
|
maghrib: {
|
|
|
tr: "Akşam",
|
|
|
en: "Maghrib",
|
|
|
de: "Abendgebet",
|
|
|
ar: "صلاة المغرب"
|
|
|
},
|
|
|
isha: {
|
|
|
tr: "Yatsı",
|
|
|
en: "Isha",
|
|
|
de: "Nachtgebet",
|
|
|
ar: "صلاة العشاء"
|
|
|
}
|
|
|
};
|
|
|
|
|
|
constructor({date, ...rest}, clock = () => new Date()) {
|
|
|
this.date = date;
|
|
|
this.clock = clock;
|
|
|
this.salathTimes = rest;
|
|
|
}
|
|
|
|
|
|
get times() {
|
|
|
const now = this.clock();
|
|
|
return PrayerTimes.salaths.map((k) => {
|
|
|
const startsAt = new Date(this.date.replace("T00:00", `T${this.salathTimes[k]}`).replace(/Z$/, ""));
|
|
|
|
|
|
return {
|
|
|
salath: k,
|
|
|
name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
|
|
|
startsAt,
|
|
|
timeLocal: this.salathTimes[k],
|
|
|
get untilSeconds() {
|
|
|
const untilSeconds = (startsAt - now) / 1000;
|
|
|
return now > startsAt ? 0 : untilSeconds;
|
|
|
},
|
|
|
get untilHuman() {
|
|
|
return formatDuration(this.untilSeconds);
|
|
|
}
|
|
|
};
|
|
|
});
|
|
|
}
|
|
|
|
|
|
get currentSalath() {
|
|
|
let current = this.times.filter((it) => it.untilSeconds === 0).at(-1);
|
|
|
if (current === undefined) {
|
|
|
const prevDay = new Date(this.date);
|
|
|
prevDay.setDate(prevDay.getDate() - 1);
|
|
|
|
|
|
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
|
|
|
}
|
|
|
return current;
|
|
|
}
|
|
|
|
|
|
get nextSalath() {
|
|
|
let next = this.times.filter((it) => it.untilSeconds > 0)[0];
|
|
|
if (next === undefined) {
|
|
|
const nextDay = new Date(this.date);
|
|
|
nextDay.setDate(nextDay.getDate() + 1);
|
|
|
|
|
|
next = new PrayerTimes({date: nextDay.toISOString(), ...this.salathTimes}, this.clock).times[0];
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
...next
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function formatDuration(seconds) {
|
|
|
const totalSeconds = Math.max(0, Math.floor(seconds));
|
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
|
const secs = totalSeconds % 60;
|
|
|
|
|
|
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
|
|
}
|
|
|
|
|
|
function formatTime(then) {
|
|
|
return new Intl.DateTimeFormat(navigator.language, {
|
|
|
hour: "numeric",
|
|
|
minute: "numeric",
|
|
|
second: "numeric"
|
|
|
}).format(then);
|
|
|
}
|
|
|
|
|
|
function formatDate(then) {
|
|
|
return new Intl.DateTimeFormat(navigator.language, {
|
|
|
year: "numeric",
|
|
|
month: "2-digit",
|
|
|
day: "2-digit"
|
|
|
}).format(then);
|
|
|
}
|
|
|
|
|
|
async function fetchJSON(url, req = {}) {
|
|
|
const res = await fetch(url, {
|
|
|
...req
|
|
|
});
|
|
|
|
|
|
if (!res.ok) {
|
|
|
throw new Error(`request failed with status ${res.status}`);
|
|
|
}
|
|
|
|
|
|
return res.json();
|
|
|
}
|
|
|
|
|
|
function getUserLocation() {
|
|
|
return new Promise((resolve, reject) => {
|
|
|
if (!navigator.geolocation) {
|
|
|
reject(new Error("Geolocation is not supported by this browser."));
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
(position) => resolve(position.coords),
|
|
|
(error) => reject(error)
|
|
|
);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function normalizeLanguageTag(tag) {
|
|
|
return String(tag).toLowerCase().split("-")[0];
|
|
|
}
|
|
|
|
|
|
function parseCoordinate(value) {
|
|
|
const parsed = Number.parseFloat(value);
|
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
|
}
|
|
|
|
|
|
function parseCoordsFromSearchParams(params) {
|
|
|
const latitude = parseCoordinate(params.get("latitude") ?? params.get("lat"));
|
|
|
const longitude = parseCoordinate(params.get("longitude") ?? params.get("lon"));
|
|
|
if (latitude === null || longitude === null) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
return {latitude, longitude};
|
|
|
}
|
|
|
|
|
|
function getHashSearchParams() {
|
|
|
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
|
|
if (hash === "") {
|
|
|
return new URLSearchParams();
|
|
|
}
|
|
|
return new URLSearchParams(hash);
|
|
|
}
|
|
|
|
|
|
function hasDebugHashFlag() {
|
|
|
const params = getHashSearchParams();
|
|
|
return params.has("debug") || location.hash === "#debug";
|
|
|
}
|
|
|
|
|
|
function getURLCoords() {
|
|
|
const queryCoords = parseCoordsFromSearchParams(new URLSearchParams(location.search));
|
|
|
if (queryCoords) {
|
|
|
return queryCoords;
|
|
|
}
|
|
|
|
|
|
return parseCoordsFromSearchParams(getHashSearchParams());
|
|
|
}
|
|
|
|
|
|
function setURLCoords(latitude, longitude) {
|
|
|
const url = new URL(window.location.href);
|
|
|
url.searchParams.set("latitude", String(latitude));
|
|
|
url.searchParams.set("longitude", String(longitude));
|
|
|
url.searchParams.delete("lat");
|
|
|
url.searchParams.delete("lon");
|
|
|
window.history.replaceState({}, "", url.toString());
|
|
|
}
|