From 93af84cef89f3e2f83d01c3dc6f140e65fd1579b Mon Sep 17 00:00:00 2001 From: Abdussamet Kocak Date: Sun, 5 Mar 2023 14:41:34 +0100 Subject: [PATCH] feat: Add Diyanet API provider feat: Add UI --- .dockerignore | 2 + go.mod | 16 +- go.sum | 23 +- internal/api/api.go | 36 +++- internal/{scrapeutils => net}/utils.go | 2 +- main.go | 13 +- pkg/diyanet/scraper.go | 10 +- pkg/diyanet/scraper_test.go | 10 +- pkg/diyanetapi/provider.go | 100 +++++++++ pkg/diyanetapi/provider_test.go | 35 ++++ pkg/prayer/types.go | 10 + templates/static/alpine.persist@3.x.x.min.js | 1 + templates/static/alpine@3.11.1.min.js | 5 + templates/static/index.html | 98 +++++++++ templates/static/main.js | 208 +++++++++++++++++++ templates/static/style.css | 88 ++++++++ templates/templates.go | 24 +++ 17 files changed, 640 insertions(+), 41 deletions(-) create mode 100644 .dockerignore rename internal/{scrapeutils => net}/utils.go (98%) create mode 100644 pkg/diyanetapi/provider.go create mode 100644 pkg/diyanetapi/provider_test.go create mode 100644 templates/static/alpine.persist@3.x.x.min.js create mode 100644 templates/static/alpine@3.11.1.min.js create mode 100644 templates/static/index.html create mode 100644 templates/static/main.js create mode 100644 templates/static/style.css create mode 100644 templates/templates.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dfd2b70 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +/.idea +*.sqlite3* \ No newline at end of file diff --git a/go.mod b/go.mod index 0b3fbb8..d4451b6 100644 --- a/go.mod +++ b/go.mod @@ -4,18 +4,21 @@ go 1.20 require ( github.com/PuerkitoBio/goquery v1.8.1 + github.com/doug-martin/goqu/v9 v9.18.0 + github.com/gofiber/fiber/v2 v2.42.0 github.com/imroc/req/v3 v3.32.3 + github.com/rs/zerolog v1.29.0 + github.com/samber/lo v1.37.0 github.com/stretchr/testify v1.8.2 + modernc.org/sqlite v1.21.0 ) require ( github.com/andybalholm/brotli v1.0.5 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/doug-martin/goqu/v9 v9.18.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect - github.com/gofiber/fiber/v2 v2.42.0 // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 // indirect github.com/google/uuid v1.3.0 // indirect @@ -23,10 +26,11 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.16.0 // indirect - github.com/kr/pretty v0.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/onsi/ginkgo/v2 v2.9.0 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -36,9 +40,6 @@ require ( github.com/quic-go/quic-go v0.33.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rogpeppe/go-internal v1.9.0 // indirect - github.com/rs/zerolog v1.29.0 // indirect - github.com/samber/lo v1.37.0 // indirect github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect github.com/tinylib/msgp v1.1.8 // indirect @@ -52,7 +53,7 @@ require ( golang.org/x/sys v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect golang.org/x/tools v0.6.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect @@ -61,7 +62,6 @@ require ( modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect modernc.org/opt v0.1.3 // indirect - modernc.org/sqlite v1.21.0 // indirect modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index 15529e0..9e5a17c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= @@ -44,13 +45,11 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo= github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -62,6 +61,9 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo/v2 v2.9.0 h1:Tugw2BKlNHTMfG+CheOITkYvk4LAh6MFOvikhGVnhE8= github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= github.com/onsi/gomega v1.27.1 h1:rfztXRbg6nv/5f+Raen9RcGoSecHIFgBBLQK3Wdj754= @@ -85,9 +87,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= @@ -100,6 +99,7 @@ github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1Avp github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -152,6 +152,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -196,10 +197,8 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -210,6 +209,8 @@ modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY= modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= @@ -222,5 +223,7 @@ modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow= modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI= modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= diff --git a/internal/api/api.go b/internal/api/api.go index 52ace65..dbb6d7c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -9,10 +9,12 @@ import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/favicon" + "github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/fiber/v2/middleware/logger" recover "github.com/gofiber/fiber/v2/middleware/recover" "prayertimes/pkg/prayer" + "prayertimes/templates" ) type httpError struct { @@ -20,7 +22,8 @@ type httpError struct { } type Services struct { - PrayerTimesProvider prayer.TimesProvider + TimesProvider prayer.TimesProvider + LocationTimesProvider prayer.LocationTimesProvider } func New(services Services) *fiber.App { @@ -42,29 +45,48 @@ func New(services Services) *fiber.App { app.Use( favicon.New(), - recover.New(recover.Config{ - EnableStackTrace: true, - }), + recover.New(recover.Config{EnableStackTrace: true}), logger.New(), cors.New(), ) - cacheHeader := fmt.Sprintf("max-age=%0f", (time.Hour * 24).Seconds()) app.Get("/healthz", func(ctx *fiber.Ctx) error { return ctx.SendString("OK") }) + app.Get("/api/v1/diyanet/prayertimes", func(ctx *fiber.Ctx) error { + var times []prayer.Times + var err error + locationID := ctx.Query("location_id") + var loc struct { + Latitude float64 `query:"latitude"` + Longitude float64 `query:"longitude"` + } + + if locationID != "" { + times, err = services.TimesProvider.Get(ctx.Context(), locationID) + } else if err := ctx.QueryParser(&loc); err == nil { + times, err = services.LocationTimesProvider.GetByCoords(ctx.Context(), prayer.Coordinates{ + Latitude: loc.Latitude, + Longitude: loc.Latitude, + }) + } else { + return fmt.Errorf("%w: missing location id or coordinates", fiber.ErrBadRequest) + } - times, err := services.PrayerTimesProvider.Get(ctx.Context(), locationID) if err != nil { return err } - ctx.Response().Header.Add(fiber.HeaderCacheControl, cacheHeader) + ctx.Response().Header.Add(fiber.HeaderCacheControl, "max-age=86400") return ctx.JSON(times) }) + app.Use("/", filesystem.New(filesystem.Config{ + Root: templates.HttpFS(), + })) + return app } diff --git a/internal/scrapeutils/utils.go b/internal/net/utils.go similarity index 98% rename from internal/scrapeutils/utils.go rename to internal/net/utils.go index 8cf9b6c..2abcd21 100644 --- a/internal/scrapeutils/utils.go +++ b/internal/net/utils.go @@ -1,4 +1,4 @@ -package scrapeutils +package net import ( "context" diff --git a/main.go b/main.go index ddcdf8a..7a17205 100644 --- a/main.go +++ b/main.go @@ -9,9 +9,9 @@ import ( "prayertimes/internal/api" "prayertimes/internal/database" - "prayertimes/internal/scrapeutils" + "prayertimes/internal/net" "prayertimes/pkg/dbtimesprovider" - "prayertimes/pkg/diyanet" + "prayertimes/pkg/diyanetapi" ) func main() { @@ -31,22 +31,21 @@ func main() { } func newServices() (api.Services, func(), error) { - diyanetProvider := diyanet.Diyanet{ - FetcherFunc: scrapeutils.GetParsed, - } + diyanetAPIProvider := diyanetapi.New(net.ReqClient) db, err := database.NewSqliteDB(getDefaultEnv("DATABASE_URL", "app.sqlite3")) if err != nil { return api.Services{}, nil, fmt.Errorf("failed to init db: %w", err) } - dbProvider := dbtimesprovider.New(db, diyanetProvider) + dbProvider := dbtimesprovider.New(db, diyanetAPIProvider) if err := dbtimesprovider.Migrate(db.Db.(*sql.DB)); err != nil { return api.Services{}, nil, fmt.Errorf("failed to migrate database: %w", err) } return api.Services{ - PrayerTimesProvider: dbProvider, + TimesProvider: dbProvider, + LocationTimesProvider: diyanetAPIProvider, }, func() { defer db.Db.(*sql.DB).Close() }, nil diff --git a/pkg/diyanet/scraper.go b/pkg/diyanet/scraper.go index 5a0aa02..a42e7c3 100644 --- a/pkg/diyanet/scraper.go +++ b/pkg/diyanet/scraper.go @@ -15,10 +15,14 @@ type Fetcher interface { FetchParsed(ctx context.Context, url string) (*goquery.Document, error) } -type Diyanet struct { +type Provider struct { FetcherFunc func(ctx context.Context, url string) (*goquery.Document, error) } +func New(fetcherFunc func(ctx context.Context, url string) (*goquery.Document, error)) *Provider { + return &Provider{FetcherFunc: fetcherFunc} +} + var reNumeric = regexp.MustCompile(`\d+`) func validateLocation(location string) error { @@ -28,7 +32,7 @@ func validateLocation(location string) error { return nil } -func (d Diyanet) Get(ctx context.Context, location string) ([]prayer.Times, error) { +func (d Provider) Get(ctx context.Context, location string) ([]prayer.Times, error) { if err := validateLocation(location); err != nil { return nil, fmt.Errorf("%w: %v", prayer.ErrInvalidLocation, err) } @@ -63,6 +67,6 @@ func (d Diyanet) Get(ctx context.Context, location string) ([]prayer.Times, erro return times, err } -func (d Diyanet) Name() string { +func (d Provider) Name() string { return "diyanet" } diff --git a/pkg/diyanet/scraper_test.go b/pkg/diyanet/scraper_test.go index 09952df..ee013e0 100644 --- a/pkg/diyanet/scraper_test.go +++ b/pkg/diyanet/scraper_test.go @@ -9,7 +9,7 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" - "prayertimes/internal/scrapeutils" + "prayertimes/internal/net" "prayertimes/pkg/prayer" ) @@ -62,13 +62,13 @@ const mockHtml = ` func TestDiyanet_Get(t *testing.T) { t.Run("validates location", func(t *testing.T) { - d := Diyanet{} + d := Provider{} _, err := d.Get(context.Background(), " not numeric ") assert.ErrorIs(t, err, prayer.ErrInvalidLocation) }) t.Run("extracts prayer times", func(t *testing.T) { - d := Diyanet{ + d := Provider{ FetcherFunc: mockFetcher(mockHtml).Fetch, } actual, err := d.Get(context.Background(), "1234") @@ -102,8 +102,8 @@ func TestDiyanet_Get(t *testing.T) { t.Skip() } - d := Diyanet{ - FetcherFunc: scrapeutils.GetParsed, + d := Provider{ + FetcherFunc: net.GetParsed, } times, err := d.Get(context.Background(), "9205") assert.NoError(t, err) diff --git a/pkg/diyanetapi/provider.go b/pkg/diyanetapi/provider.go new file mode 100644 index 0000000..5f5e145 --- /dev/null +++ b/pkg/diyanetapi/provider.go @@ -0,0 +1,100 @@ +package diyanetapi + +import ( + "context" + "fmt" + "time" + + "github.com/imroc/req/v3" + + "prayertimes/pkg/prayer" +) + +type Provider struct { + http *req.Client +} + +func New(c *req.Client) Provider { + return Provider{ + http: c.Clone().SetCommonBasicAuth("diyanet", "Q6Y3vYt5F3x2txPaaMF3uPgbK99EJhpM"), + } +} + +func (d Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) { + res, err := d.http.NewRequest(). + SetContext(ctx). + SetQueryParams(map[string]string{ + "latitude": fmt.Sprintf("%f", coords.Latitude), + "longitude": fmt.Sprintf("%f", coords.Longitude), + }). + Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik") + if err != nil { + return nil, fmt.Errorf("failed to get prayer times by coords: %w", err) + } + + return d.parseResponse(res) +} + +func (d Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { + res, err := d.http.NewRequest(). + SetContext(ctx). + SetQueryParam("ilceId", locationID). + Get("https://namazvakti.diyanet.gov.tr/api/NamazVakti/Aylik") + if err != nil { + return nil, fmt.Errorf("failed to get prayer times by location id: %w", err) + } + + return d.parseResponse(res) +} + +func (d Provider) parseResponse(res *req.Response) ([]prayer.Times, 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"` + Fajr time.Time `json:"imsak"` + Sunrise time.Time `json:"gunes"` + Dhuhr time.Time `json:"ogle"` + Asr time.Time `json:"ikindi"` + Maghrib time.Time `json:"aksam"` + Isha time.Time `json:"yatsi"` + } `json:"namazVakti"` + } `json:"resultObject"` + } + if err := res.Unmarshal(&response); err != nil { + return nil, fmt.Errorf("failed to unmarshal as json: %w", err) + } + if !response.Success { + return nil, fmt.Errorf("received error: %s", res.String()) + } + + var times []prayer.Times + const format = "15:04" + now := time.Now() + for _, pt := range response.ResultObject.PrayerTimes { + then := pt.Date.UTC().Truncate(time.Hour * 24) + if then.Before(now) { + continue + } + + times = append(times, prayer.Times{ + Date: then, + Fajr: pt.Fajr.Format(format), + Sunrise: pt.Sunrise.Format(format), + Dhuhr: pt.Dhuhr.Format(format), + Asr: pt.Asr.Format(format), + Maghrib: pt.Maghrib.Format(format), + Isha: pt.Isha.Format(format), + }) + } + return times, nil +} + +func (d Provider) Name() string { + return "diyanet" +} diff --git a/pkg/diyanetapi/provider_test.go b/pkg/diyanetapi/provider_test.go new file mode 100644 index 0000000..ad9be48 --- /dev/null +++ b/pkg/diyanetapi/provider_test.go @@ -0,0 +1,35 @@ +package diyanetapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "prayertimes/internal/net" + "prayertimes/pkg/prayer" +) + +func TestDiyanetAPI_GetByCoords(t *testing.T) { + p := New(net.ReqClient) + t.Run("by coords", func(t *testing.T) { + t.Parallel() + + times, err := p.GetByCoords(context.Background(), prayer.Coordinates{ + Latitude: 52.5100846, + Longitude: 13.4518284, + }) + assert.NoError(t, err) + assert.NotEmpty(t, times) + t.Logf("%#+v", times[0]) + }) + + t.Run("by id", func(t *testing.T) { + t.Parallel() + + times, err := p.Get(context.Background(), "11104") + assert.NoError(t, err) + assert.NotEmpty(t, times) + t.Logf("%#+v", times[0]) + }) +} diff --git a/pkg/prayer/types.go b/pkg/prayer/types.go index b6ce915..0581f57 100644 --- a/pkg/prayer/types.go +++ b/pkg/prayer/types.go @@ -13,6 +13,16 @@ type TimesProvider interface { Name() string } +type Coordinates struct { + Latitude float64 + Longitude float64 +} + +type LocationTimesProvider interface { + GetByCoords(ctx context.Context, coords Coordinates) ([]Times, error) + Name() string +} + type Times struct { Date time.Time `json:"date"` Fajr string `json:"fajr"` diff --git a/templates/static/alpine.persist@3.x.x.min.js b/templates/static/alpine.persist@3.x.x.min.js new file mode 100644 index 0000000..bcd83c5 --- /dev/null +++ b/templates/static/alpine.persist@3.x.x.min.js @@ -0,0 +1 @@ +(()=>{function d(t){let e=()=>{let n,l=localStorage;return t.interceptor((r,i,s,a,m)=>{let o=n||`_x_${a}`,u=f(o,l)?g(o,l):r;return s(u),t.effect(()=>{let c=i();p(o,c,l),s(c)}),u},r=>{r.as=i=>(n=i,r),r.using=i=>(l=i,r)})};Object.defineProperty(t,"$persist",{get:()=>e()}),t.magic("persist",e),t.persist=(n,{get:l,set:r},i=localStorage)=>{let s=f(n,i)?g(n,i):l();r(s),t.effect(()=>{let a=l();p(n,a,i),r(a)})}}function f(t,e){return e.getItem(t)!==null}function g(t,e){return JSON.parse(e.getItem(t,e))}function p(t,e,n){n.setItem(t,JSON.stringify(e))}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(d)});})(); diff --git a/templates/static/alpine@3.11.1.min.js b/templates/static/alpine@3.11.1.min.js new file mode 100644 index 0000000..0c826a2 --- /dev/null +++ b/templates/static/alpine@3.11.1.min.js @@ -0,0 +1,5 @@ +(()=>{var Ye=!1,Ze=!1,V=[];function Ft(e){mn(e)}function mn(e){V.includes(e)||V.push(e),hn()}function xe(e){let t=V.indexOf(e);t!==-1&&V.splice(t,1)}function hn(){!Ze&&!Ye&&(Ye=!0,queueMicrotask(_n))}function _n(){Ye=!1,Ze=!0;for(let e=0;ee.effect(t,{scheduler:r=>{Xe?Ft(r):r()}}),Qe=e.raw}function et(e){I=e}function zt(e){let t=()=>{};return[n=>{let i=I(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}var Vt=[],Ht=[],qt=[];function Ut(e){qt.push(e)}function ye(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Ht.push(t))}function Wt(e){Vt.push(e)}function Gt(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function tt(e,t){!e._x_attributeCleanups||Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}var nt=new MutationObserver(rt),it=!1;function ie(){nt.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),it=!0}function ot(){gn(),nt.disconnect(),it=!1}var oe=[],st=!1;function gn(){oe=oe.concat(nt.takeRecords()),oe.length&&!st&&(st=!0,queueMicrotask(()=>{xn(),st=!1}))}function xn(){rt(oe),oe.length=0}function h(e){if(!it)return e();ot();let t=e();return ie(),t}var at=!1,be=[];function Jt(){at=!0}function Yt(){at=!1,rt(be),be=[]}function rt(e){if(at){be=be.concat(e);return}let t=[],r=[],n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.push(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.push(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{tt(s,o)}),n.forEach((o,s)=>{Vt.forEach(a=>a(s,o))});for(let o of r)if(!t.includes(o)&&(Ht.forEach(s=>s(o)),o._x_cleanups))for(;o._x_cleanups.length;)o._x_cleanups.pop()();t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.includes(o)||!o.isConnected||(delete o._x_ignoreSelf,delete o._x_ignore,qt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function ve(e){return j(L(e))}function M(e,t,r){return e._x_dataStack=[t,...L(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function ct(e,t){let r=e._x_dataStack[0];Object.entries(t).forEach(([n,i])=>{r[n]=i})}function L(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?L(e.host):e.parentNode?L(e.parentNode):[]}function j(e){let t=new Proxy({},{ownKeys:()=>Array.from(new Set(e.flatMap(r=>Object.keys(r)))),has:(r,n)=>e.some(i=>i.hasOwnProperty(n)),get:(r,n)=>(e.find(i=>{if(i.hasOwnProperty(n)){let o=Object.getOwnPropertyDescriptor(i,n);if(o.get&&o.get._x_alreadyBound||o.set&&o.set._x_alreadyBound)return!0;if((o.get||o.set)&&o.enumerable){let s=o.get,a=o.set,c=o;s=s&&s.bind(t),a=a&&a.bind(t),s&&(s._x_alreadyBound=!0),a&&(a._x_alreadyBound=!0),Object.defineProperty(i,n,{...c,get:s,set:a})}return!0}return!1})||{})[n],set:(r,n,i)=>{let o=e.find(s=>s.hasOwnProperty(n));return o?o[n]=i:e[e.length-1][n]=i,!0}});return t}function we(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Ee(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>yn(n,i),s=>lt(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function yn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function lt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),lt(e[t[0]],t.slice(1),r)}}var Zt={};function y(e,t){Zt[e]=t}function se(e,t){return Object.entries(Zt).forEach(([r,n])=>{Object.defineProperty(e,`$${r}`,{get(){let[i,o]=ut(t);return i={interceptor:Ee,...i},ye(t,o),n(t,i)},enumerable:!1})}),e}function Qt(e,t,r,...n){try{return r(...n)}catch(i){Z(i,e,t)}}function Z(e,t,r=void 0){Object.assign(e,{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Se=!0;function Xt(e){let t=Se;Se=!1,e(),Se=t}function P(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return er(...e)}var er=ft;function tr(e){er=e}function ft(e,t){let r={};se(r,e);let n=[r,...L(e)];if(typeof t=="function")return bn(n,t);let i=vn(n,t,e);return Qt.bind(null,e,t,i)}function bn(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(j([n,...e]),i);Ae(r,o)}}var dt={};function wn(e,t){if(dt[e])return dt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e)||/^(let|const)\s/.test(e)?`(async()=>{ ${e} })()`:e,o=(()=>{try{return new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`)}catch(s){return Z(s,t,e),Promise.resolve()}})();return dt[e]=o,o}function vn(e,t,r){let n=wn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=j([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>Z(l,r,t));n.finished?(Ae(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ae(i,l,a,s,r)}).catch(l=>Z(l,r,t)).finally(()=>n.result=void 0)}}}function Ae(e,t,r,n,i){if(Se&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ae(e,s,r,n)).catch(s=>Z(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var pt="x-";function S(e=""){return pt+e}function rr(e){pt=e}var mt={};function p(e,t){return mt[e]=t,{before(r){if(!mt[r]){console.warn("Cannot find directive `${directive}`. `${name}` will use the default order of execution");return}let n=H.indexOf(r)??H.indexOf("DEFAULT");n>=0&&H.splice(n,0,e)}}}function ae(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=ht(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(nr((o,s)=>n[o]=s)).filter(ir).map(Sn(n,r)).sort(An).map(o=>En(e,o))}function ht(e){return Array.from(e).map(nr()).filter(t=>!ir(t))}var _t=!1,ce=new Map,or=Symbol();function sr(e){_t=!0;let t=Symbol();or=t,ce.set(t,[]);let r=()=>{for(;ce.get(t).length;)ce.get(t).shift()();ce.delete(t)},n=()=>{_t=!1,r()};e(r),n()}function ut(e){let t=[],r=a=>t.push(a),[n,i]=zt(e);return t.push(i),[{Alpine:F,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:P.bind(P,e)},()=>t.forEach(a=>a())]}function En(e,t){let r=()=>{},n=mt[t.type]||r,[i,o]=ut(e);Gt(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),_t?ce.get(or).push(n):n())};return s.runCleanups=o,s}var Oe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Te=e=>e;function nr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=ar.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var ar=[];function Q(e){ar.push(e)}function ir({name:e}){return cr().test(e)}var cr=()=>new RegExp(`^${pt}([^:^.]+)\\b`);function Sn(e,t){return({name:r,value:n})=>{let i=r.match(cr()),o=r.match(/:([a-zA-Z0-9\-:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var gt="DEFAULT",H=["ignore","ref","data","id","radio","tabs","switch","disclosure","menu","listbox","combobox","bind","init","for","mask","model","modelable","transition","show","if",gt,"teleport"];function An(e,t){let r=H.indexOf(e.type)===-1?gt:e.type,n=H.indexOf(t.type)===-1?gt:t.type;return H.indexOf(r)-H.indexOf(n)}function q(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function A(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>A(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)A(n,t,!1),n=n.nextElementSibling}function C(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}function ur(){document.body||C("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + + + + + + +
+ + + + + + + + +
+

+
+
+ + \ No newline at end of file diff --git a/templates/static/main.js b/templates/static/main.js new file mode 100644 index 0000000..fe67ee0 --- /dev/null +++ b/templates/static/main.js @@ -0,0 +1,208 @@ +const app = () => ({ + futureTimes: [], + + locationId: Alpine.$persist('11104'), + lastUpdated: Alpine.$persist(null), + userMinutes: 0, + now: new Date(), + debug: location.hash === '#debug', + geolocation: null, + + async init() { + await this.refreshIfStale(); + setInterval(() => { + this.now = new Date(); + }, 500); + + getUserLocation() + .then(loc => { + this.geolocation = {latitude: loc.latitude, longitude: loc.longitude}; + this.refreshIfStale(); + }) + .catch(() => this.geolocation = null) + }, + + onHash() { + this.debug = location.hash === '#debug' + }, + + get userNow() { + if (!this.debug) { + return this.now; + } + + const d = new Date(); + d.setHours(0, this.userMinutes, 0, 0); + return d; + }, + + get userTime() { + return formatTime(this.userNow); + }, + + async refreshIfStale() { + const updatedAt = new Date(this.lastUpdated); + const now = new Date(); + + const elapsedSeconds = (now - updatedAt) / 1000; + + if (this.geolocation !== null) { + this.futureTimes = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${this.geolocation.latitude}&longitude=${this.geolocation.longitude}`); + } else { + this.futureTimes = await fetchJSON(`/api/v1/diyanet/prayertimes?location_id=${this.locationId}`); + } + this.lastUpdated = now.toISOString(); + }, + + get todayTimes() { + if (this.futureTimes.length === 0) { + return null; + } + return new PrayerTimes(this.futureTimes[0], () => this.userNow); + }, + + translate(key, lang) { + return translations[key][lang] ?? key + } +}); + +class PrayerTimes { + static salaths = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha']; + static translations = { + fajr: {tr: 'İmsak', de: 'Frühgebet', ar: 'صلاة الفجر'}, + sunrise: {tr: 'Güneş', de: 'Sonnenaufgang', ar: 'الشروق'}, + dhuhr: {tr: 'Öğle', de: 'Mittagsgebet', ar: 'صلاة الظهر'}, + asr: {tr: 'İkindi', de: 'Nachmittagsgebet', ar: 'صلاة العصر'}, + maghrib: {tr: 'Akşam', de: 'Abendgebet', ar: 'صلاة المغرب'}, + isha: {tr: 'Yatsı', de: 'Nachtgebet', ar: 'صلاة العشاء'}, + } + + constructor({date, ...rest}, clock = () => new Date()) { + this.date = date; + this.clock = clock + this.salathTimes = rest + } + + get times() { + const now = this.clock() + return PrayerTimes.salaths.map(k => { + // "2023-03-05T00:00:00Z" + const startsAt = new Date(this.date.replace('T00:00', `T${this.salathTimes[k]}`).replace(/Z$/, '')); + + return { + salath: k, + name: lang => PrayerTimes.translations[k][lang] ?? '??', + startsAt, + timeLocal: this.salathTimes[k], + get untilSeconds() { + let untilSeconds = (startsAt - now) / 1000; + return now > startsAt ? 0 : untilSeconds; + }, + get untilHuman() { + return formatDuration(this.untilSeconds) + }, + } + }) + } + + get currentSalath() { + let current = this.times.filter(it => it.untilSeconds === 0).at(-1); + if (current === undefined) { + // we're in isha -> today's fajr is almost the same as tomorrows + const prevDay = new Date(this.date); + prevDay.setDate(prevDay.getDate() - 1); + + current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1); + } + return current + } + + get nextSalath() { + let next = this.times + .filter(it => it.untilSeconds > 0)[0] + if (next === undefined) { + // we're in isha -> today's fajr is almost the same as tomorrows + const nextDay = new Date(this.date); + nextDay.setDate(nextDay.getDate() + 1); + + next = new PrayerTimes({date: nextDay.toISOString(), ...this.salathTimes}, this.clock).times[0]; + } + + return { + ...next, + } + } +} + +/** + * @param {number} seconds + * @return {string} + * */ +function formatDuration(seconds) { + const d = new Date(0, 0, 0, 0, 0, seconds); + return formatTime(d); +} + +/** + * @param {Date} then + * @return {string} + * */ +function formatTime(then) { + return new Intl.DateTimeFormat(navigator.language, { + hour: "numeric", + minute: "numeric", + second: "numeric" + }).format(then); +} + +/** + * @param {Date} then + * @return {string} + * */ +function formatDate(then) { + return new Intl.DateTimeFormat(navigator.language, { + year: "numeric", + month: "2-digit", + day: "2-digit" + }).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 = {}) { + const res = await fetch(url, { + ...req, + }) + return res.json() +} + +/** + * @return {Promise} + * */ +function getUserLocation() { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject("Geolocation is not supported by this browser."); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => resolve(position.coords), + (error) => reject(error.message) + ); + }); +} \ No newline at end of file diff --git a/templates/static/style.css b/templates/static/style.css new file mode 100644 index 0000000..1d2b321 --- /dev/null +++ b/templates/static/style.css @@ -0,0 +1,88 @@ +*, *:before, *:after { + box-sizing: border-box; +} + +body { + line-height: 1.6; + font-family: 'Inter', sans-serif; +} + +p { + margin: 0; +} + +p + p { + margin-top: 1rem; +} + +.text--numeric { + font-variant: tabular-nums; + letter-spacing: -0.03em; +} + +.text--center { + text-align: center; +} + +.text--arabic { + font-family: 'Noto Sans Arabic', Inter, sans-serif; + line-height: 1; +} + +.text--time { + font-size: 1.5em; +} + +.table-wrapper { + padding: 0 2rem; + max-width: 66%; + margin: auto; +} + +.salath-table { + font-size: 2rem; + font-weight: 600; + + width: 100%; + vertical-align: middle; +} + +td { + padding: 0.25rem 0.5rem; +} + +.cell-names { + white-space: nowrap; +} + +.cell-times { + white-space: nowrap; + text-align: right; +} + +.salath-name { +} + +.current { + color: tomato; +} + +.salath-name + .salath-name:before { + content: '/'; + opacity: 0.25; + margin: 0 0.5em; +} + +.salath-time { + font-weight: 700; +} + +.clock { + font-size: 4rem; + font-weight: bold; +} + +.current-salath { + font-weight: bold; + font-size: 1.5rem; +} \ No newline at end of file diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 0000000..2eb5dae --- /dev/null +++ b/templates/templates.go @@ -0,0 +1,24 @@ +package templates + +import ( + "embed" + "io/fs" + "net/http" + "os" +) + +//go:embed static +var staticFS embed.FS + +func FS() fs.FS { + if os.Getenv("DEBUG") == "1" { + return os.DirFS("./templates/static") + } + + f, _ := fs.Sub(staticFS, "static") + return f +} + +func HttpFS() http.FileSystem { + return http.FS(FS()) +}