feat: Return location info and Hijri date from the prayer times endpoint

master
Abdussamet Kocak 4 weeks ago
parent 07a9703a89
commit c106d57fe6

@ -16,6 +16,7 @@ import (
"github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/recover"
"github.com/gofiber/fiber/v3/middleware/static" "github.com/gofiber/fiber/v3/middleware/static"
"prayertimes/pkg/hijricalendar"
"prayertimes/pkg/prayer" "prayertimes/pkg/prayer"
"prayertimes/templates" "prayertimes/templates"
) )
@ -30,8 +31,8 @@ type Services struct {
} }
type PrayerProvider interface { type PrayerProvider interface {
Get(ctx context.Context, locationID string) ([]prayer.Times, error) Get(ctx context.Context, locationID string) (prayer.TimesResult, error)
GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error)
} }
type LocationProvider interface { type LocationProvider interface {
@ -71,6 +72,7 @@ func New(services Services) *fiber.App {
LocationID string `query:"location_id"` LocationID string `query:"location_id"`
Latitude string `query:"latitude"` Latitude string `query:"latitude"`
Longitude string `query:"longitude"` Longitude string `query:"longitude"`
UTC string `query:"utc"`
} }
if err := ctx.Bind().Query(&query); err != nil { if err := ctx.Bind().Query(&query); err != nil {
return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err)) return fmt.Errorf("failed to bind prayer times query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
@ -79,15 +81,16 @@ func New(services Services) *fiber.App {
locationID := strings.TrimSpace(query.LocationID) locationID := strings.TrimSpace(query.LocationID)
latitude := strings.TrimSpace(query.Latitude) latitude := strings.TrimSpace(query.Latitude)
longitude := strings.TrimSpace(query.Longitude) longitude := strings.TrimSpace(query.Longitude)
utc := strings.TrimSpace(query.UTC) == "1"
var ( var (
times []prayer.Times result prayer.TimesResult
err error err error
) )
switch { switch {
case locationID != "": case locationID != "":
times, err = services.PrayerProvider.Get(ctx.Context(), locationID) result, err = services.PrayerProvider.Get(ctx.Context(), locationID)
case latitude != "" && longitude != "": case latitude != "" && longitude != "":
lat, latErr := strconv.ParseFloat(latitude, 64) lat, latErr := strconv.ParseFloat(latitude, 64)
if latErr != nil { if latErr != nil {
@ -98,7 +101,7 @@ func New(services Services) *fiber.App {
return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr)) return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr))
} }
times, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{
Latitude: lat, Latitude: lat,
Longitude: lng, Longitude: lng,
}) })
@ -110,9 +113,33 @@ func New(services Services) *fiber.App {
return fmt.Errorf("failed to fetch prayer times: %w", err) return fmt.Errorf("failed to fetch prayer times: %w", err)
} }
location := result.Location
if location.Latitude != 0 || location.Longitude != 0 {
locations, locErr := services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
})
if locErr != nil {
return fmt.Errorf("failed to enrich prayer times location from database: %w", locErr)
}
if len(locations) > 0 {
dbLocation := locations[0]
if strings.TrimSpace(dbLocation.Timezone) == "" {
dbLocation.Timezone = location.Timezone
}
location = dbLocation
}
}
mappedTimes, err := mapPrayerTimesForResponse(result.Times, location, utc)
if err != nil {
return fmt.Errorf("failed to map prayer times for response: %w", err)
}
ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400") ctx.Response().Header.Set(fiber.HeaderCacheControl, "max-age=86400")
return ctx.JSON(fiber.Map{ return ctx.JSON(fiber.Map{
"prayertimes": times, "location": location,
"prayertimes": mappedTimes,
}) })
}) })
@ -169,3 +196,100 @@ func New(services Services) *fiber.App {
return app return app
} }
type prayerTimesResponse struct {
Date string `json:"date"`
DateHijri string `json:"date_hijri"`
Fajr string `json:"fajr"`
Sunrise string `json:"sunrise"`
Dhuhr string `json:"dhuhr"`
Asr string `json:"asr"`
Sunset string `json:"sunset,omitempty"`
Maghrib string `json:"maghrib"`
Isha string `json:"isha"`
}
func mapPrayerTimesForResponse(times []prayer.Times, location prayer.Location, utc bool) ([]any, error) {
if utc {
result := make([]any, 0, len(times))
for _, item := range times {
dateHijri := item.DateHijri
if dateHijri == "" {
dateHijri = hijricalendar.ToISODate(item.Date)
}
result = append(result, prayer.Times{
Date: item.Date.UTC(),
DateHijri: dateHijri,
Fajr: item.Fajr.UTC(),
Sunrise: item.Sunrise.UTC(),
Dhuhr: item.Dhuhr.UTC(),
Asr: item.Asr.UTC(),
Sunset: item.Sunset.UTC(),
Maghrib: item.Maghrib.UTC(),
Isha: item.Isha.UTC(),
})
}
return result, nil
}
tz := time.UTC
if strings.TrimSpace(location.Timezone) != "" {
loadedTZ, err := loadTimezone(location.Timezone)
if err != nil {
return nil, fmt.Errorf("failed to load location timezone: %w", err)
}
tz = loadedTZ
}
result := make([]any, 0, len(times))
for _, item := range times {
dateHijri := item.DateHijri
if dateHijri == "" {
dateHijri = hijricalendar.ToISODate(item.Date)
}
result = append(result, prayerTimesResponse{
Date: item.Date.In(tz).Format(time.DateOnly),
DateHijri: dateHijri,
Fajr: formatHHMM(item.Fajr, tz),
Sunrise: formatHHMM(item.Sunrise, tz),
Dhuhr: formatHHMM(item.Dhuhr, tz),
Asr: formatHHMM(item.Asr, tz),
Sunset: formatHHMM(item.Sunset, tz),
Maghrib: formatHHMM(item.Maghrib, tz),
Isha: formatHHMM(item.Isha, tz),
})
}
return result, nil
}
func formatHHMM(value time.Time, tz *time.Location) string {
if value.IsZero() {
return ""
}
return value.In(tz).Format("15:04")
}
func loadTimezone(name string) (*time.Location, error) {
if strings.HasPrefix(name, "UTC") {
offsetText := strings.TrimPrefix(name, "UTC")
if offsetText == "" {
return time.UTC, nil
}
offsetHours, err := strconv.Atoi(offsetText)
if err != nil {
return nil, fmt.Errorf("failed to parse utc offset timezone: %w", err)
}
return time.FixedZone(name, offsetHours*3600), nil
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf("failed to load iana timezone: %w", err)
}
return loc, nil
}

@ -48,6 +48,7 @@ type locationRow struct {
ASCIIName string `db:"ascii_name"` ASCIIName string `db:"ascii_name"`
AlternateNames string `db:"alternate_names"` AlternateNames string `db:"alternate_names"`
CountryCode string `db:"country_code"` CountryCode string `db:"country_code"`
Timezone string `db:"timezone"`
Latitude float64 `db:"latitude"` Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"` Longitude float64 `db:"longitude"`
DistanceSq float64 `db:"distance_sq"` DistanceSq float64 `db:"distance_sq"`
@ -69,6 +70,7 @@ func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.L
goqu.I("ascii_name"), goqu.I("ascii_name"),
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"), goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
goqu.I("country_code"), goqu.I("country_code"),
goqu.I("timezone"),
goqu.I("latitude"), goqu.I("latitude"),
goqu.I("longitude"), goqu.I("longitude"),
goqu.V(0).As("distance_sq"), goqu.V(0).As("distance_sq"),
@ -96,6 +98,7 @@ func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.L
ASCIIName: row.ASCIIName, ASCIIName: row.ASCIIName,
AlternateNames: row.AlternateNames, AlternateNames: row.AlternateNames,
CountryCode: row.CountryCode, CountryCode: row.CountryCode,
Timezone: row.Timezone,
Latitude: row.Latitude, Latitude: row.Latitude,
Longitude: row.Longitude, Longitude: row.Longitude,
}) })
@ -113,6 +116,7 @@ func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coo
goqu.I("ascii_name"), goqu.I("ascii_name"),
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"), goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
goqu.I("country_code"), goqu.I("country_code"),
goqu.I("timezone"),
goqu.I("latitude"), goqu.I("latitude"),
goqu.I("longitude"), goqu.I("longitude"),
goqu.L( goqu.L(
@ -142,6 +146,7 @@ func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coo
ASCIIName: row.ASCIIName, ASCIIName: row.ASCIIName,
AlternateNames: row.AlternateNames, AlternateNames: row.AlternateNames,
CountryCode: row.CountryCode, CountryCode: row.CountryCode,
Timezone: row.Timezone,
Latitude: row.Latitude, Latitude: row.Latitude,
Longitude: row.Longitude, Longitude: row.Longitude,
}) })

@ -8,6 +8,7 @@ import (
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
"prayertimes/pkg/hijricalendar"
"prayertimes/pkg/prayer" "prayertimes/pkg/prayer"
) )
@ -21,18 +22,20 @@ func New(c *req.Client) Provider {
} }
} }
func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) { func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
locationID, err := d.getLocationIDByCoords(ctx, coords) locationID, err := d.getLocationIDByCoords(ctx, coords)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to resolve location by coordinates: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to resolve location by coordinates: %w", err)
} }
times, err := d.Get(ctx, locationID) result, err := d.Get(ctx, locationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times by coordinates: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by coordinates: %w", err)
} }
result.Location.Latitude = coords.Latitude
result.Location.Longitude = coords.Longitude
return times, nil return result, nil
} }
func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coordinates) (string, error) { func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coordinates) (string, error) {
@ -66,27 +69,32 @@ func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coord
return fmt.Sprintf("%d", response.ResultObject[0].ID), nil return fmt.Sprintf("%d", response.ResultObject[0].ID), nil
} }
func (d Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { func (d Provider) Get(ctx context.Context, locationID string) (prayer.TimesResult, error) {
res, err := d.http.NewRequest(). res, err := d.http.NewRequest().
SetContext(ctx). SetContext(ctx).
SetQueryParam("ilceId", locationID). SetQueryParam("ilceId", locationID).
Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Gunluk") Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Gunluk")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times by location id: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by location id: %w", err)
} }
times, err := d.parseResponse(res) result, err := d.parseResponse(res)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse prayer times response: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to parse prayer times response: %w", err)
} }
result.Location.ID = parseIntOrZero(locationID)
return times, nil return result, nil
} }
func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) { func (d Provider) parseResponse(res *req.Response) (prayer.TimesResult, error) {
var response struct { var response struct {
Success bool `json:"success"` Success bool `json:"success"`
ResultObject struct { ResultObject struct {
Location struct {
ID int `json:"konum_Id"`
Timezone string `json:"timezone"`
} `json:"konum"`
PrayerTimes []struct { PrayerTimes []struct {
Date time.Time `json:"miladi_tarih_uzun_Iso8601"` Date time.Time `json:"miladi_tarih_uzun_Iso8601"`
DateIslamic string `json:"hicri_tarih_uzun"` DateIslamic string `json:"hicri_tarih_uzun"`
@ -101,14 +109,14 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) {
} `json:"resultObject"` } `json:"resultObject"`
} }
if err := res.Unmarshal(&response); err != nil { if err := res.Unmarshal(&response); err != nil {
return nil, fmt.Errorf("failed to unmarshal as json: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to unmarshal as json: %w", err)
} }
if !response.Success { if !response.Success {
return nil, fmt.Errorf("failed to get prayer times from upstream: %w", errors.New(res.String())) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times from upstream: %w", errors.New(res.String()))
} }
if len(response.ResultObject.PrayerTimes) == 0 { if len(response.ResultObject.PrayerTimes) == 0 {
return nil, nil return prayer.TimesResult{}, nil
} }
var times []prayer.Times var times []prayer.Times
@ -120,16 +128,29 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) {
} }
times = append(times, prayer.Times{ times = append(times, prayer.Times{
Date: pt.Date.Format(time.DateOnly), Date: pt.Date.UTC(),
DateIslamic: pt.DateIslamic, DateHijri: hijricalendar.ToISODate(pt.Date.UTC()),
Fajr: pt.Fajr.Format("15:04"), Fajr: pt.Fajr.UTC(),
Sunrise: pt.Sunrise.Format("15:04"), Sunrise: pt.Sunrise.UTC(),
Dhuhr: pt.Dhuhr.Format("15:04"), Dhuhr: pt.Dhuhr.UTC(),
Asr: pt.Asr.Format("15:04"), Asr: pt.Asr.UTC(),
Sunset: pt.Sunset.Format("15:04"), Sunset: pt.Sunset.UTC(),
Maghrib: pt.Maghrib.Format("15:04"), Maghrib: pt.Maghrib.UTC(),
Isha: pt.Isha.Format("15:04"), Isha: pt.Isha.UTC(),
}) })
} }
return times, nil
return prayer.TimesResult{
Location: prayer.Location{
ID: response.ResultObject.Location.ID,
Timezone: response.ResultObject.Location.Timezone,
},
Times: times,
}, nil
}
func parseIntOrZero(value string) int {
var out int
_, _ = fmt.Sscanf(value, "%d", &out)
return out
} }

@ -15,37 +15,37 @@ func TestDiyanetAPI_GetByCoords(t *testing.T) {
t.Run("by coords", func(t *testing.T) { t.Run("by coords", func(t *testing.T) {
t.Parallel() t.Parallel()
times, err := p.GetByCoords(context.Background(), prayer.Coordinates{ result, err := p.GetByCoords(context.Background(), prayer.Coordinates{
Latitude: 52.5100846, Latitude: 52.5100846,
Longitude: 13.4518284, Longitude: 13.4518284,
}) })
if err != nil { if err != nil {
t.Skipf("skipping live endpoint test due to upstream/network error: %v", err) t.Skipf("skipping live endpoint test due to upstream/network error: %v", err)
} }
if len(times) == 0 { if len(result.Times) == 0 {
t.Skip("skipping live endpoint test because upstream returned no times") t.Skip("skipping live endpoint test because upstream returned no times")
} }
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, times) assert.NotEmpty(t, result.Times)
t.Logf("%#+v", times[0]) t.Logf("location=%+v first=%+v", result.Location, result.Times[0])
}) })
t.Run("by id", func(t *testing.T) { t.Run("by id", func(t *testing.T) {
t.Parallel() t.Parallel()
times, err := p.Get(context.Background(), "11104") result, err := p.Get(context.Background(), "11104")
if err != nil { if err != nil {
t.Skipf("skipping live endpoint test due to upstream/network error: %v", err) t.Skipf("skipping live endpoint test due to upstream/network error: %v", err)
} }
if len(times) == 0 { if len(result.Times) == 0 {
t.Skip("skipping live endpoint test because upstream returned no times") t.Skip("skipping live endpoint test because upstream returned no times")
} }
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, times) assert.NotEmpty(t, result.Times)
for _, time := range times { for _, item := range result.Times {
t.Log(time) t.Log(item)
} }
}) })
} }

@ -7,6 +7,7 @@ import (
"math" "math"
"time" "time"
"prayertimes/pkg/hijricalendar"
"prayertimes/pkg/prayer" "prayertimes/pkg/prayer"
) )
@ -84,11 +85,11 @@ func (Provider) SearchLocations(_ context.Context, _ string) ([]prayer.Location,
return nil, fmt.Errorf("failed to search locations: %w", errNotSupported) return nil, fmt.Errorf("failed to search locations: %w", errNotSupported)
} }
func (Provider) Get(_ context.Context, _ string) ([]prayer.Times, error) { func (Provider) Get(_ context.Context, _ string) (prayer.TimesResult, error) {
return nil, fmt.Errorf("failed to get prayer times by location id: %w", errNotSupported) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times by location id: %w", errNotSupported)
} }
func (Provider) GetByCoords(_ context.Context, coords prayer.Coordinates) ([]prayer.Times, error) { func (Provider) GetByCoords(_ context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
offset := estimateUTCOffsetHours(coords.Longitude) offset := estimateUTCOffsetHours(coords.Longitude)
todayUTC := time.Now().UTC().Truncate(24 * time.Hour) todayUTC := time.Now().UTC().Truncate(24 * time.Hour)
@ -98,18 +99,26 @@ func (Provider) GetByCoords(_ context.Context, coords prayer.Coordinates) ([]pra
calculated := prayerTimes(coords.Latitude, coords.Longitude, day) calculated := prayerTimes(coords.Latitude, coords.Longitude, day)
results = append(results, prayer.Times{ results = append(results, prayer.Times{
Date: day.Format(time.DateOnly), Date: day,
Fajr: formatHHMM(calculated.Imsak, offset), DateHijri: hijricalendar.ToISODate(day),
Sunrise: formatHHMM(calculated.Sunrise, offset), Fajr: derefOrZero(calculated.Imsak),
Dhuhr: formatHHMM(calculated.Dhuhr, offset), Sunrise: derefOrZero(calculated.Sunrise),
Asr: formatHHMM(calculated.Asr, offset), Dhuhr: derefOrZero(calculated.Dhuhr),
Sunset: formatHHMM(calculated.Sunset, offset), Asr: derefOrZero(calculated.Asr),
Maghrib: formatHHMM(calculated.Maghrib, offset), Sunset: derefOrZero(calculated.Sunset),
Isha: formatHHMM(calculated.Isha, offset), Maghrib: derefOrZero(calculated.Maghrib),
Isha: derefOrZero(calculated.Isha),
}) })
} }
return results, nil return prayer.TimesResult{
Location: prayer.Location{
Latitude: coords.Latitude,
Longitude: coords.Longitude,
Timezone: formatUTCOffsetTimezone(offset),
},
Times: results,
}, nil
} }
// Convert a calendar date to a Julian Day Number. // Convert a calendar date to a Julian Day Number.
@ -290,16 +299,15 @@ func prayerTimes(lat, lon float64, d time.Time) computedTimes {
} }
} }
// Convert UTC prayer time datetimes to HH:MM strings at a given UTC offset. func derefOrZero(dt *time.Time) time.Time {
//
// tzOffset is UTC offset in hours, e.g. 3 for Turkey (UTC+3).
func formatHHMM(dt *time.Time, tzOffset float64) string {
if dt == nil { if dt == nil {
return "" return time.Time{}
}
return dt.UTC()
} }
tz := time.FixedZone("estimated", int(tzOffset*3600)) func formatUTCOffsetTimezone(offset float64) string {
return dt.In(tz).Add(30 * time.Second).Format("15:04") return fmt.Sprintf("UTC%+d", int(offset))
} }
func estimateUTCOffsetHours(longitude float64) float64 { func estimateUTCOffsetHours(longitude float64) float64 {

@ -10,12 +10,12 @@ import (
) )
type APIProvider interface { type APIProvider interface {
Get(ctx context.Context, locationID string) ([]prayer.Times, error) Get(ctx context.Context, locationID string) (prayer.TimesResult, error)
GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error)
} }
type FallbackProvider interface { type FallbackProvider interface {
GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error)
} }
type Provider struct { type Provider struct {
@ -28,44 +28,44 @@ func New(api APIProvider, fallback FallbackProvider) Provider {
return Provider{ return Provider{
api: api, api: api,
fallback: fallback, fallback: fallback,
timeout: 2 * time.Second, timeout: 1 * time.Second,
} }
} }
var ErrEmptyTimes = errors.New("diyanet did not return any prayer times") var ErrEmptyTimes = errors.New("diyanet did not return any prayer times")
func (p Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { func (p Provider) Get(ctx context.Context, locationID string) (prayer.TimesResult, error) {
ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout) ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout)
defer cancel() defer cancel()
times, err := p.api.Get(ctxWithTimeout, locationID) result, err := p.api.Get(ctxWithTimeout, locationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times from api provider: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times from api provider: %w", err)
} }
if len(times) == 0 { if len(result.Times) == 0 {
return nil, ErrEmptyTimes return prayer.TimesResult{}, ErrEmptyTimes
} }
return times, nil return result, nil
} }
func (p Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) { func (p Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) {
ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout) ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout)
defer cancel() defer cancel()
times, err := p.api.GetByCoords(ctxWithTimeout, coords) result, err := p.api.GetByCoords(ctxWithTimeout, coords)
if err == nil && len(times) > 0 { if err == nil && len(result.Times) > 0 {
return times, nil return result, nil
} }
times, err = p.fallback.GetByCoords(ctx, coords) result, err = p.fallback.GetByCoords(ctx, coords)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", err) return prayer.TimesResult{}, fmt.Errorf("failed to get prayer times from fallback provider: %w", err)
} }
if len(times) == 0 { if len(result.Times) == 0 {
return nil, fmt.Errorf("fallback provider did not return any prayer times") return prayer.TimesResult{}, fmt.Errorf("fallback provider did not return any prayer times")
} }
return times, nil return result, nil
} }

@ -0,0 +1,61 @@
package hijricalendar
import (
"fmt"
"math"
"time"
)
// JulianToIslamic converts a Julian calendar date to the Islamic (Hijri) calendar.
// Algorithm from Jean Meeus, "Astronomical Algorithms", 2nd ed.
func JulianToIslamic(year, month, day int) (iYear, iMonth, iDay int) {
jdn := julianToJDN(year, month, day)
return jdnToIslamic(jdn)
}
// GregorianToIslamic converts a Gregorian calendar date to the Islamic (Hijri)
// calendar.
func GregorianToIslamic(year, month, day int) (iYear, iMonth, iDay int) {
jdn := gregorianToJDN(year, month, day)
return jdnToIslamic(jdn)
}
// julianToJDN returns the Julian Day Number for a date on the Julian calendar.
func julianToJDN(year, month, day int) int {
y, m := year, month
if m <= 2 {
y--
m += 12
}
return int(math.Floor(365.25*float64(y+4716))) +
int(math.Floor(30.6001*float64(m+1))) +
day - 1524
}
func gregorianToJDN(year, month, day int) int {
a := (14 - month) / 12
y := year + 4800 - a
m := month + 12*a - 3
return day + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
}
// jdnToIslamic converts a Julian Day Number to an Islamic (Hijri) calendar date.
// Source: F. de Moya, as cited in Meeus Ch. 9.
func jdnToIslamic(jd int) (year, month, day int) {
l := jd - 1948440 + 10632
n := (l - 1) / 10631
l = l - 10631*n + 354
j := (10985-l)/5316*(50*l/17719) + (l/5670)*(43*l/15238)
l = l - (30-j)/15*(17719*j/50) - (j/16)*(15238*j/43) + 29
year = 30*n + j - 30
month = (24 * l) / 709
day = l - (709*month)/24
return
}
func ToISODate(t time.Time) string {
year, month, day := t.UTC().Date()
hYear, hMonth, hDay := GregorianToIslamic(year, int(month), day)
return fmt.Sprintf("%04d-%02d-%02d", hYear, hMonth, hDay)
}

@ -0,0 +1,45 @@
package hijricalendar
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToISODate(t *testing.T) {
tests := []struct {
name string
date time.Time
want string
}{
{
name: "ramadan 3 1447",
date: time.Date(2026, 2, 20, 0, 0, 0, 0, time.UTC),
want: "1447-09-03",
},
{
name: "ramadan 1 1447",
date: time.Date(2026, 2, 18, 0, 0, 0, 0, time.UTC),
want: "1447-09-01",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ToISODate(tt.date)
require.NotEmpty(t, got)
assert.Equal(t, tt.want, got)
})
}
}
func TestGregorianToIslamicRange(t *testing.T) {
y, m, d := GregorianToIslamic(2026, 2, 20)
assert.Positive(t, y)
assert.GreaterOrEqual(t, m, 1)
assert.LessOrEqual(t, m, 12)
assert.GreaterOrEqual(t, d, 1)
assert.LessOrEqual(t, d, 30)
}

@ -2,6 +2,7 @@ package prayer
import ( import (
"errors" "errors"
"time"
) )
var ErrInvalidLocation = errors.New("invalid location") var ErrInvalidLocation = errors.New("invalid location")
@ -19,16 +20,22 @@ type Location struct {
CountryCode string `json:"country_code"` CountryCode string `json:"country_code"`
Latitude float64 `json:"latitude"` Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
Timezone string `json:"timezone,omitempty"`
} }
type Times struct { type Times struct {
Date string `json:"date"` Date time.Time `json:"date"`
DateIslamic string `json:"date_islamic,omitempty"` DateHijri string `json:"date_hijri"`
Fajr string `json:"fajr"` Fajr time.Time `json:"fajr"`
Sunrise string `json:"sunrise"` Sunrise time.Time `json:"sunrise"`
Dhuhr string `json:"dhuhr"` Dhuhr time.Time `json:"dhuhr"`
Asr string `json:"asr"` Asr time.Time `json:"asr"`
Sunset string `json:"sunset,omitempty"` Sunset time.Time `json:"sunset,omitempty"`
Maghrib string `json:"maghrib"` Maghrib time.Time `json:"maghrib"`
Isha string `json:"isha"` Isha time.Time `json:"isha"`
}
type TimesResult struct {
Location Location `json:"location"`
Times []Times `json:"prayertimes"`
} }

Loading…
Cancel
Save