feat: Return location info and Hijri date from the prayer times endpoint
This commit is contained in:
+130
-6
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
+45
-24
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-19
@@ -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)
|
||||||
|
}
|
||||||
+16
-9
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user