diff --git a/internal/api/api.go b/internal/api/api.go index 8e2bbb4..4f9b021 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -16,6 +16,7 @@ import ( "github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/static" + "prayertimes/pkg/hijricalendar" "prayertimes/pkg/prayer" "prayertimes/templates" ) @@ -30,8 +31,8 @@ type Services struct { } type PrayerProvider interface { - Get(ctx context.Context, locationID string) ([]prayer.Times, error) - GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) + Get(ctx context.Context, locationID string) (prayer.TimesResult, error) + GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) } type LocationProvider interface { @@ -71,6 +72,7 @@ func New(services Services) *fiber.App { LocationID string `query:"location_id"` Latitude string `query:"latitude"` Longitude string `query:"longitude"` + UTC string `query:"utc"` } if err := ctx.Bind().Query(&query); err != nil { 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) latitude := strings.TrimSpace(query.Latitude) longitude := strings.TrimSpace(query.Longitude) + utc := strings.TrimSpace(query.UTC) == "1" var ( - times []prayer.Times - err error + result prayer.TimesResult + err error ) switch { case locationID != "": - times, err = services.PrayerProvider.Get(ctx.Context(), locationID) + result, err = services.PrayerProvider.Get(ctx.Context(), locationID) case latitude != "" && longitude != "": lat, latErr := strconv.ParseFloat(latitude, 64) 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)) } - times, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ + result, err = services.PrayerProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ Latitude: lat, Longitude: lng, }) @@ -110,9 +113,33 @@ func New(services Services) *fiber.App { 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") return ctx.JSON(fiber.Map{ - "prayertimes": times, + "location": location, + "prayertimes": mappedTimes, }) }) @@ -169,3 +196,100 @@ func New(services Services) *fiber.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 +} diff --git a/pkg/citydb/provider.go b/pkg/citydb/provider.go index aa2746e..96910e2 100644 --- a/pkg/citydb/provider.go +++ b/pkg/citydb/provider.go @@ -48,6 +48,7 @@ type locationRow struct { ASCIIName string `db:"ascii_name"` AlternateNames string `db:"alternate_names"` CountryCode string `db:"country_code"` + Timezone string `db:"timezone"` Latitude float64 `db:"latitude"` Longitude float64 `db:"longitude"` 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.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"), goqu.I("country_code"), + goqu.I("timezone"), goqu.I("latitude"), goqu.I("longitude"), goqu.V(0).As("distance_sq"), @@ -96,6 +98,7 @@ func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.L ASCIIName: row.ASCIIName, AlternateNames: row.AlternateNames, CountryCode: row.CountryCode, + Timezone: row.Timezone, Latitude: row.Latitude, Longitude: row.Longitude, }) @@ -113,6 +116,7 @@ func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coo goqu.I("ascii_name"), goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"), goqu.I("country_code"), + goqu.I("timezone"), goqu.I("latitude"), goqu.I("longitude"), goqu.L( @@ -142,6 +146,7 @@ func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coo ASCIIName: row.ASCIIName, AlternateNames: row.AlternateNames, CountryCode: row.CountryCode, + Timezone: row.Timezone, Latitude: row.Latitude, Longitude: row.Longitude, }) diff --git a/pkg/diyanetapi/provider.go b/pkg/diyanetapi/provider.go index 17a9624..23c4641 100644 --- a/pkg/diyanetapi/provider.go +++ b/pkg/diyanetapi/provider.go @@ -8,6 +8,7 @@ import ( "github.com/imroc/req/v3" + "prayertimes/pkg/hijricalendar" "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) 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 { - 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) { @@ -66,27 +69,32 @@ func (d Provider) getLocationIDByCoords(ctx context.Context, coords prayer.Coord 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(). SetContext(ctx). SetQueryParam("ilceId", locationID). Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Gunluk") 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 { - 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 { Success bool `json:"success"` ResultObject struct { + Location struct { + ID int `json:"konum_Id"` + Timezone string `json:"timezone"` + } `json:"konum"` PrayerTimes []struct { Date time.Time `json:"miladi_tarih_uzun_Iso8601"` DateIslamic string `json:"hicri_tarih_uzun"` @@ -101,14 +109,14 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) { } `json:"resultObject"` } 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 { - 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 { - return nil, nil + return prayer.TimesResult{}, nil } var times []prayer.Times @@ -120,16 +128,29 @@ func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, error) { } times = append(times, prayer.Times{ - Date: pt.Date.Format(time.DateOnly), - DateIslamic: pt.DateIslamic, - Fajr: pt.Fajr.Format("15:04"), - Sunrise: pt.Sunrise.Format("15:04"), - Dhuhr: pt.Dhuhr.Format("15:04"), - Asr: pt.Asr.Format("15:04"), - Sunset: pt.Sunset.Format("15:04"), - Maghrib: pt.Maghrib.Format("15:04"), - Isha: pt.Isha.Format("15:04"), + Date: pt.Date.UTC(), + DateHijri: hijricalendar.ToISODate(pt.Date.UTC()), + Fajr: pt.Fajr.UTC(), + Sunrise: pt.Sunrise.UTC(), + Dhuhr: pt.Dhuhr.UTC(), + Asr: pt.Asr.UTC(), + Sunset: pt.Sunset.UTC(), + Maghrib: pt.Maghrib.UTC(), + 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 } diff --git a/pkg/diyanetapi/provider_test.go b/pkg/diyanetapi/provider_test.go index b7e6010..569b179 100644 --- a/pkg/diyanetapi/provider_test.go +++ b/pkg/diyanetapi/provider_test.go @@ -15,37 +15,37 @@ func TestDiyanetAPI_GetByCoords(t *testing.T) { t.Run("by coords", func(t *testing.T) { t.Parallel() - times, err := p.GetByCoords(context.Background(), prayer.Coordinates{ + result, err := p.GetByCoords(context.Background(), prayer.Coordinates{ Latitude: 52.5100846, Longitude: 13.4518284, }) if err != nil { 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") } assert.NoError(t, err) - assert.NotEmpty(t, times) - t.Logf("%#+v", times[0]) + assert.NotEmpty(t, result.Times) + t.Logf("location=%+v first=%+v", result.Location, result.Times[0]) }) t.Run("by id", func(t *testing.T) { t.Parallel() - times, err := p.Get(context.Background(), "11104") + result, err := p.Get(context.Background(), "11104") if err != nil { 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") } assert.NoError(t, err) - assert.NotEmpty(t, times) - for _, time := range times { - t.Log(time) + assert.NotEmpty(t, result.Times) + for _, item := range result.Times { + t.Log(item) } }) } diff --git a/pkg/diyanetcalc/provider.go b/pkg/diyanetcalc/provider.go index a01b8d0..70918b2 100644 --- a/pkg/diyanetcalc/provider.go +++ b/pkg/diyanetcalc/provider.go @@ -7,6 +7,7 @@ import ( "math" "time" + "prayertimes/pkg/hijricalendar" "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) } -func (Provider) Get(_ context.Context, _ string) ([]prayer.Times, error) { - return nil, fmt.Errorf("failed to get prayer times by location id: %w", errNotSupported) +func (Provider) Get(_ context.Context, _ string) (prayer.TimesResult, error) { + 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) 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) results = append(results, prayer.Times{ - Date: day.Format(time.DateOnly), - Fajr: formatHHMM(calculated.Imsak, offset), - Sunrise: formatHHMM(calculated.Sunrise, offset), - Dhuhr: formatHHMM(calculated.Dhuhr, offset), - Asr: formatHHMM(calculated.Asr, offset), - Sunset: formatHHMM(calculated.Sunset, offset), - Maghrib: formatHHMM(calculated.Maghrib, offset), - Isha: formatHHMM(calculated.Isha, offset), + Date: day, + DateHijri: hijricalendar.ToISODate(day), + Fajr: derefOrZero(calculated.Imsak), + Sunrise: derefOrZero(calculated.Sunrise), + Dhuhr: derefOrZero(calculated.Dhuhr), + Asr: derefOrZero(calculated.Asr), + Sunset: derefOrZero(calculated.Sunset), + 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. @@ -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. -// -// tzOffset is UTC offset in hours, e.g. 3 for Turkey (UTC+3). -func formatHHMM(dt *time.Time, tzOffset float64) string { +func derefOrZero(dt *time.Time) time.Time { if dt == nil { - return "" + return time.Time{} } + return dt.UTC() +} - tz := time.FixedZone("estimated", int(tzOffset*3600)) - return dt.In(tz).Add(30 * time.Second).Format("15:04") +func formatUTCOffsetTimezone(offset float64) string { + return fmt.Sprintf("UTC%+d", int(offset)) } func estimateUTCOffsetHours(longitude float64) float64 { diff --git a/pkg/diyanetprovider/provider.go b/pkg/diyanetprovider/provider.go index eee277e..f88ddcc 100644 --- a/pkg/diyanetprovider/provider.go +++ b/pkg/diyanetprovider/provider.go @@ -10,12 +10,12 @@ import ( ) type APIProvider interface { - Get(ctx context.Context, locationID string) ([]prayer.Times, error) - GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) + Get(ctx context.Context, locationID string) (prayer.TimesResult, error) + GetByCoords(ctx context.Context, coords prayer.Coordinates) (prayer.TimesResult, error) } 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 { @@ -28,44 +28,44 @@ func New(api APIProvider, fallback FallbackProvider) Provider { return Provider{ api: api, fallback: fallback, - timeout: 2 * time.Second, + timeout: 1 * time.Second, } } 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) defer cancel() - times, err := p.api.Get(ctxWithTimeout, locationID) + result, err := p.api.Get(ctxWithTimeout, locationID) 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 { - return nil, ErrEmptyTimes + if len(result.Times) == 0 { + 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) defer cancel() - times, err := p.api.GetByCoords(ctxWithTimeout, coords) - if err == nil && len(times) > 0 { - return times, nil + result, err := p.api.GetByCoords(ctxWithTimeout, coords) + if err == nil && len(result.Times) > 0 { + return result, nil } - times, err = p.fallback.GetByCoords(ctx, coords) + result, err = p.fallback.GetByCoords(ctx, coords) 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 { - return nil, fmt.Errorf("fallback provider did not return any prayer times") + if len(result.Times) == 0 { + return prayer.TimesResult{}, fmt.Errorf("fallback provider did not return any prayer times") } - return times, nil + return result, nil } diff --git a/pkg/hijricalendar/calendar.go b/pkg/hijricalendar/calendar.go new file mode 100644 index 0000000..9f03570 --- /dev/null +++ b/pkg/hijricalendar/calendar.go @@ -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) +} diff --git a/pkg/hijricalendar/calendar_test.go b/pkg/hijricalendar/calendar_test.go new file mode 100644 index 0000000..5b3b690 --- /dev/null +++ b/pkg/hijricalendar/calendar_test.go @@ -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) +} diff --git a/pkg/prayer/types.go b/pkg/prayer/types.go index 66fd216..cf4a355 100644 --- a/pkg/prayer/types.go +++ b/pkg/prayer/types.go @@ -2,6 +2,7 @@ package prayer import ( "errors" + "time" ) var ErrInvalidLocation = errors.New("invalid location") @@ -19,16 +20,22 @@ type Location struct { CountryCode string `json:"country_code"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` + Timezone string `json:"timezone,omitempty"` } type Times struct { - Date string `json:"date"` - DateIslamic string `json:"date_islamic,omitempty"` - 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"` + Date time.Time `json:"date"` + DateHijri string `json:"date_hijri"` + Fajr time.Time `json:"fajr"` + Sunrise time.Time `json:"sunrise"` + Dhuhr time.Time `json:"dhuhr"` + Asr time.Time `json:"asr"` + Sunset time.Time `json:"sunset,omitempty"` + Maghrib time.Time `json:"maghrib"` + Isha time.Time `json:"isha"` +} + +type TimesResult struct { + Location Location `json:"location"` + Times []Times `json:"prayertimes"` }