feat: Add location search

master
Abdussamet Kocak 4 weeks ago
parent 26af8af8a9
commit a53b200c21

@ -36,6 +36,7 @@ type PrayerProvider interface {
type LocationProvider interface { type LocationProvider interface {
SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error)
SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error)
} }
func New(services Services) *fiber.App { func New(services Services) *fiber.App {
@ -117,18 +118,43 @@ func New(services Services) *fiber.App {
app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error { app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error {
var query struct { var query struct {
Text string `query:"query"` Text string `query:"query"`
Latitude string `query:"latitude"`
Longitude string `query:"longitude"`
} }
if err := ctx.Bind().Query(&query); err != nil { if err := ctx.Bind().Query(&query); err != nil {
return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
} }
query.Text = strings.TrimSpace(query.Text) query.Text = strings.TrimSpace(query.Text)
if query.Text == "" { query.Latitude = strings.TrimSpace(query.Latitude)
query.Longitude = strings.TrimSpace(query.Longitude)
var (
locations []prayer.Location
err error
)
switch {
case query.Text != "" && query.Latitude == "" && query.Longitude == "":
locations, err = services.LocationProvider.SearchLocations(ctx.Context(), query.Text)
case query.Text == "" && query.Latitude != "" && query.Longitude != "":
lat, latErr := strconv.ParseFloat(query.Latitude, 64)
if latErr != nil {
return fmt.Errorf("failed to parse latitude query parameter: %w", errors.Join(fiber.ErrBadRequest, latErr))
}
lng, lngErr := strconv.ParseFloat(query.Longitude, 64)
if lngErr != nil {
return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr))
}
locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
Latitude: lat,
Longitude: lng,
})
default:
return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest) return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest)
} }
locations, err := services.LocationProvider.SearchLocations(ctx.Context(), query.Text)
if err != nil { if err != nil {
return fmt.Errorf("failed to search locations: %w", err) return fmt.Errorf("failed to search locations: %w", err)
} }

@ -35,7 +35,11 @@ func Open(path string) (Provider, error) {
} }
func (p Provider) Close() error { func (p Provider) Close() error {
return p.db.Db.(*sql.DB).Close() if err := p.db.Db.(*sql.DB).Close(); err != nil {
return fmt.Errorf("failed to close cities database: %w", err)
}
return nil
} }
type locationRow struct { type locationRow struct {
@ -46,6 +50,7 @@ type locationRow struct {
CountryCode string `db:"country_code"` CountryCode string `db:"country_code"`
Latitude float64 `db:"latitude"` Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"` Longitude float64 `db:"longitude"`
DistanceSq float64 `db:"distance_sq"`
} }
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) { func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) {
@ -66,6 +71,7 @@ func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.L
goqu.I("country_code"), goqu.I("country_code"),
goqu.I("latitude"), goqu.I("latitude"),
goqu.I("longitude"), goqu.I("longitude"),
goqu.V(0).As("distance_sq"),
). ).
Where( Where(
goqu.Or( goqu.Or(
@ -97,3 +103,49 @@ func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.L
return locations, nil return locations, nil
} }
func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error) {
q := p.db.
From("cities").
Select(
goqu.I("geoname_id"),
goqu.I("name"),
goqu.I("ascii_name"),
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
goqu.I("country_code"),
goqu.I("latitude"),
goqu.I("longitude"),
goqu.L(
"((latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?))",
coords.Latitude,
coords.Latitude,
coords.Longitude,
coords.Longitude,
).As("distance_sq"),
).
Order(
goqu.I("distance_sq").Asc(),
goqu.I("population").Desc(),
).
Limit(50)
var rows []locationRow
if err := q.ScanStructsContext(ctx, &rows); err != nil {
return nil, fmt.Errorf("failed to query locations by coordinates from cities database: %w", err)
}
locations := make([]prayer.Location, 0, len(rows))
for _, row := range rows {
locations = append(locations, prayer.Location{
ID: row.ID,
Name: row.Name,
ASCIIName: row.ASCIIName,
AlternateNames: row.AlternateNames,
CountryCode: row.CountryCode,
Latitude: row.Latitude,
Longitude: row.Longitude,
})
}
return locations, nil
}

@ -35,8 +35,47 @@
</div> </div>
</template> </template>
<input type='text' <div class='location-search'>
x-model='locationId'> <input type='search'
class='location-search__input'
placeholder='Search location...'
x-model='searchQuery'
@input='onSearchInput'
@focus='searchOpen = searchResults.length > 0'
@click.outside='searchOpen = false'>
<template x-if='searchOpen'>
<div class='location-search__dropdown'>
<template x-if='searchLoading'>
<div class='location-search__state'>Searching...</div>
</template>
<template x-if='!searchLoading && searchError'>
<div class='location-search__state'
x-text='searchError'></div>
</template>
<template x-if='!searchLoading && !searchError && searchResults.length === 0'>
<div class='location-search__state'>No results</div>
</template>
<template x-if='!searchLoading && !searchError && searchResults.length > 0'>
<ul class='location-search__results'>
<template x-for='location in searchResults'
:key='location.id'>
<li>
<button type='button'
class='location-search__item'
@click='selectLocation(location)'>
<span x-text='formatLocationLabel(location)'></span>
</button>
</li>
</template>
</ul>
</template>
</div>
</template>
</div>
<template x-if='todayTimes'> <template x-if='todayTimes'>
<div class='current-salath text--center'> <div class='current-salath text--center'>

@ -1,29 +1,115 @@
const app = () => ({ const app = () => ({
futureTimes: [], futureTimes: [],
selectedLocation: Alpine.$persist(null),
locationId: Alpine.$persist('11002'),
lastUpdated: Alpine.$persist(null), lastUpdated: Alpine.$persist(null),
searchQuery: "",
searchResults: [],
searchOpen: false,
searchLoading: false,
searchError: "",
searchDebounceTimer: null,
userMinutes: 0, userMinutes: 0,
now: new Date(), now: new Date(),
debug: location.hash === '#debug', debug: location.hash === "#debug",
geolocation: null,
async init() { async init() {
await this.refreshIfStale(); if (this.selectedLocation) {
this.searchQuery = this.formatLocationLabel(this.selectedLocation);
await this.refreshPrayerTimes();
}
setInterval(() => { setInterval(() => {
this.now = new Date(); this.now = new Date();
}, 500); }, 500);
getUserLocation() try {
.then(loc => { const coords = await getUserLocation();
this.geolocation = {latitude: loc.latitude, longitude: loc.longitude}; await this.selectNearestLocation(coords.latitude, coords.longitude);
this.refreshIfStale(); } catch (_error) {
}) // Ignore geolocation errors and rely on manual search.
.catch(() => this.geolocation = null) }
}, },
onHash() { onHash() {
this.debug = location.hash === '#debug' this.debug = location.hash === "#debug";
},
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) {
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);
},
async selectLocation(location) {
this.selectedLocation = location;
this.searchQuery = this.formatLocationLabel(location);
this.searchOpen = false;
this.searchResults = [];
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() { get userNow() {
@ -40,90 +126,66 @@ const app = () => ({
return formatTime(this.userNow); return formatTime(this.userNow);
}, },
async refreshIfStale() {
const updatedAt = new Date(this.lastUpdated);
const now = new Date();
const elapsedSeconds = (now - updatedAt) / 1000;
if (this.geolocation !== null) {
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${this.geolocation.latitude}&longitude=${this.geolocation.longitude}`);
this.futureTimes = response.prayertimes ?? [];
} else {
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?location_id=${this.locationId}`);
this.futureTimes = response.prayertimes ?? [];
}
this.lastUpdated = now.toISOString();
},
get todayTimes() { get todayTimes() {
if (this.futureTimes.length === 0) { if (this.futureTimes.length === 0) {
return null; return null;
} }
return new PrayerTimes(this.futureTimes[0], () => this.userNow); return new PrayerTimes(this.futureTimes[0], () => this.userNow);
},
translate(key, lang) {
return translations[key][lang] ?? key
} }
}); });
class PrayerTimes { class PrayerTimes {
static salaths = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha']; static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
static translations = { static translations = {
fajr: {tr: 'İmsak', de: 'Frühgebet', ar: 'صلاة الفجر'}, fajr: {tr: "İmsak", de: "Fruehgebet", ar: "صلاة الفجر"},
sunrise: {tr: 'Güneş', de: 'Sonnenaufgang', ar: 'الشروق'}, sunrise: {tr: "Günes", de: "Sonnenaufgang", ar: "الشروق"},
dhuhr: {tr: 'Öğle', de: 'Mittagsgebet', ar: 'صلاة الظهر'}, dhuhr: {tr: "Öğle", de: "Mittagsgebet", ar: "صلاة الظهر"},
asr: {tr: 'İkindi', de: 'Nachmittagsgebet', ar: 'صلاة العصر'}, asr: {tr: "İkindi", de: "Nachmittagsgebet", ar: "صلاة العصر"},
maghrib: {tr: 'Akşam', de: 'Abendgebet', ar: 'صلاة المغرب'}, maghrib: {tr: "Aksam", de: "Abendgebet", ar: "صلاة المغرب"},
isha: {tr: 'Yatsı', de: 'Nachtgebet', ar: 'صلاة العشاء'}, isha: {tr: "Yatsı", de: "Nachtgebet", ar: "صلاة العشاء"}
} };
constructor({date, ...rest}, clock = () => new Date()) { constructor({date, ...rest}, clock = () => new Date()) {
this.date = date; this.date = date;
this.clock = clock this.clock = clock;
this.salathTimes = rest this.salathTimes = rest;
} }
get times() { get times() {
const now = this.clock() const now = this.clock();
return PrayerTimes.salaths.map(k => { return PrayerTimes.salaths.map((k) => {
// "2023-03-05T00:00:00Z" const startsAt = new Date(this.date.replace("T00:00", `T${this.salathTimes[k]}`).replace(/Z$/, ""));
const startsAt = new Date(this.date.replace('T00:00', `T${this.salathTimes[k]}`).replace(/Z$/, ''));
return { return {
salath: k, salath: k,
name: lang => PrayerTimes.translations[k][lang] ?? '??', name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
startsAt, startsAt,
timeLocal: this.salathTimes[k], timeLocal: this.salathTimes[k],
get untilSeconds() { get untilSeconds() {
let untilSeconds = (startsAt - now) / 1000; const untilSeconds = (startsAt - now) / 1000;
return now > startsAt ? 0 : untilSeconds; return now > startsAt ? 0 : untilSeconds;
}, },
get untilHuman() { get untilHuman() {
return formatDuration(this.untilSeconds) return formatDuration(this.untilSeconds);
}, }
} };
}) });
} }
get currentSalath() { get currentSalath() {
let current = this.times.filter(it => it.untilSeconds === 0).at(-1); let current = this.times.filter((it) => it.untilSeconds === 0).at(-1);
if (current === undefined) { if (current === undefined) {
// we're in isha -> today's fajr is almost the same as tomorrows
const prevDay = new Date(this.date); const prevDay = new Date(this.date);
prevDay.setDate(prevDay.getDate() - 1); prevDay.setDate(prevDay.getDate() - 1);
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1); current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
} }
return current return current;
} }
get nextSalath() { get nextSalath() {
let next = this.times let next = this.times.filter((it) => it.untilSeconds > 0)[0];
.filter(it => it.untilSeconds > 0)[0]
if (next === undefined) { if (next === undefined) {
// we're in isha -> today's fajr is almost the same as tomorrows
const nextDay = new Date(this.date); const nextDay = new Date(this.date);
nextDay.setDate(nextDay.getDate() + 1); nextDay.setDate(nextDay.getDate() + 1);
@ -131,24 +193,16 @@ class PrayerTimes {
} }
return { return {
...next, ...next
} };
} }
} }
/**
* @param {number} seconds
* @return {string}
* */
function formatDuration(seconds) { function formatDuration(seconds) {
const d = new Date(0, 0, 0, 0, 0, seconds); const d = new Date(0, 0, 0, 0, 0, seconds);
return formatTime(d); return formatTime(d);
} }
/**
* @param {Date} then
* @return {string}
* */
function formatTime(then) { function formatTime(then) {
return new Intl.DateTimeFormat(navigator.language, { return new Intl.DateTimeFormat(navigator.language, {
hour: "numeric", hour: "numeric",
@ -157,10 +211,6 @@ function formatTime(then) {
}).format(then); }).format(then);
} }
/**
* @param {Date} then
* @return {string}
* */
function formatDate(then) { function formatDate(then) {
return new Intl.DateTimeFormat(navigator.language, { return new Intl.DateTimeFormat(navigator.language, {
year: "numeric", year: "numeric",
@ -169,42 +219,28 @@ function formatDate(then) {
}).format(then); }).format(then);
} }
/**
* @param {Date} then
* @return {string}
* */
function formatDateHijri(then) {
return new Intl.DateTimeFormat("en-u-ca-islamic-umalqura-nu-latn", {
year: "numeric",
month: "long",
day: "numeric",
}).format(then);
}
/**
* @param {string} url
* @param {RequestInit} req
* */
async function fetchJSON(url, req = {}) { async function fetchJSON(url, req = {}) {
const res = await fetch(url, { const res = await fetch(url, {
...req, ...req
}) });
return res.json()
if (!res.ok) {
throw new Error(`request failed with status ${res.status}`);
}
return res.json();
} }
/**
* @return {Promise<GeolocationCoordinates>}
* */
function getUserLocation() { function getUserLocation() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
reject("Geolocation is not supported by this browser."); reject(new Error("Geolocation is not supported by this browser."));
return; return;
} }
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => resolve(position.coords), (position) => resolve(position.coords),
(error) => reject(error.message) (error) => reject(error)
); );
}); });
} }

@ -7,6 +7,60 @@ body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
} }
.location-search {
position: relative;
max-width: 38rem;
margin: 1rem auto;
padding: 0 1rem;
}
.location-search__input {
width: 100%;
border: 1px solid #ddd;
border-radius: 0.75rem;
padding: 0.75rem 0.875rem;
font: inherit;
}
.location-search__dropdown {
position: absolute;
left: 1rem;
right: 1rem;
top: calc(100% + 0.25rem);
background: #fff;
border: 1px solid #ddd;
border-radius: 0.75rem;
max-height: 18rem;
overflow: auto;
z-index: 5;
}
.location-search__results {
list-style: none;
margin: 0;
padding: 0;
}
.location-search__item {
width: 100%;
border: 0;
border-bottom: 1px solid #f0f0f0;
background: transparent;
text-align: left;
padding: 0.625rem 0.75rem;
font: inherit;
cursor: pointer;
}
.location-search__item:hover {
background: #fafafa;
}
.location-search__state {
padding: 0.75rem;
color: #666;
}
p { p {
margin: 0; margin: 0;
} }
@ -39,6 +93,21 @@ p + p {
margin: auto; margin: auto;
} }
@media (max-width: 768px) {
.table-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.salath-table {
font-size: 1.375rem;
}
.clock {
font-size: 3rem;
}
}
.salath-table { .salath-table {
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;

Loading…
Cancel
Save