mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
refactor: time zone sensitivity (resolve #184)
This commit is contained in:
parent
26ef93c1af
commit
c142b525a4
@ -1,6 +1,9 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "regexp"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
mailRegex = regexp.MustCompile(MailPattern)
|
mailRegex = regexp.MustCompile(MailPattern)
|
||||||
@ -10,6 +13,7 @@ type User struct {
|
|||||||
ID string `json:"id" gorm:"primary_key"`
|
ID string `json:"id" gorm:"primary_key"`
|
||||||
ApiKey string `json:"api_key" gorm:"unique"`
|
ApiKey string `json:"api_key" gorm:"unique"`
|
||||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||||
|
Location string `json:"location"`
|
||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
@ -54,7 +58,8 @@ type CredentialsReset struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserDataUpdate struct {
|
type UserDataUpdate struct {
|
||||||
Email string `schema:"email"`
|
Email string `schema:"email"`
|
||||||
|
Location string `schema:"location"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeByUser struct {
|
type TimeByUser struct {
|
||||||
@ -67,6 +72,22 @@ type CountByUser struct {
|
|||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) TZ() *time.Location {
|
||||||
|
if u.Location == "" {
|
||||||
|
u.Location = "Local"
|
||||||
|
}
|
||||||
|
tz, err := time.LoadLocation(u.Location)
|
||||||
|
if err != nil {
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
return tz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) TZOffset() time.Duration {
|
||||||
|
_, offset := time.Now().In(u.TZ()).Zone()
|
||||||
|
return time.Duration(offset * int(time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return ValidatePassword(c.PasswordNew) &&
|
return ValidatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
c.PasswordNew == c.PasswordRepeat
|
||||||
@ -85,7 +106,7 @@ func (s *Signup) IsValid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserDataUpdate) IsValid() bool {
|
func (r *UserDataUpdate) IsValid() bool {
|
||||||
return ValidateEmail(r.Email)
|
return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateUsername(username string) bool {
|
func ValidateUsername(username string) bool {
|
||||||
@ -99,3 +120,8 @@ func ValidatePassword(password string) bool {
|
|||||||
func ValidateEmail(email string) bool {
|
func ValidateEmail(email string) bool {
|
||||||
return email == "" || mailRegex.Match([]byte(email))
|
return email == "" || mailRegex.Match([]byte(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateTimezone(tz string) bool {
|
||||||
|
_, err := time.LoadLocation(tz)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
@ -54,8 +54,8 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
|||||||
var heartbeats []*models.Heartbeat
|
var heartbeats []*models.Heartbeat
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Heartbeat{UserID: user.ID}).
|
Where(&models.Heartbeat{UserID: user.ID}).
|
||||||
Where("time >= ?", from).
|
Where("time >= ?", from.Local()).
|
||||||
Where("time < ?", to).
|
Where("time < ?", to.Local()).
|
||||||
Order("time asc").
|
Order("time asc").
|
||||||
Find(&heartbeats).Error; err != nil {
|
Find(&heartbeats).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -126,7 +126,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
|||||||
|
|
||||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where("time <= ?", t).
|
Where("time <= ?", t.Local()).
|
||||||
Delete(models.Heartbeat{}).Error; err != nil {
|
Delete(models.Heartbeat{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -40,8 +40,8 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
|||||||
var summaries []*models.Summary
|
var summaries []*models.Summary
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where(&models.Summary{UserID: user.ID}).
|
Where(&models.Summary{UserID: user.ID}).
|
||||||
Where("from_time >= ?", from).
|
Where("from_time >= ?", from.Local()).
|
||||||
Where("to_time <= ?", to).
|
Where("to_time <= ?", to.Local()).
|
||||||
Order("from_time asc").
|
Order("from_time asc").
|
||||||
Preload("Projects", "type = ?", models.SummaryProject).
|
Preload("Projects", "type = ?", models.SummaryProject).
|
||||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||||
|
@ -77,7 +77,7 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
|
|||||||
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
|
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
|
||||||
var users []*models.User
|
var users []*models.User
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
Where("last_logged_in_at >= ?", t).
|
Where("last_logged_in_at >= ?", t.Local()).
|
||||||
Find(&users).Error; err != nil {
|
Find(&users).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -96,7 +96,7 @@ func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, erro
|
|||||||
if err := r.db.
|
if err := r.db.
|
||||||
Select("user as id").
|
Select("user as id").
|
||||||
Table("(?) as q", subQuery1).
|
Table("(?) as q", subQuery1).
|
||||||
Where("time >= ?", t).
|
Where("time >= ?", t.Local()).
|
||||||
Scan(&userIds).Error; err != nil {
|
Scan(&userIds).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -142,6 +142,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
|||||||
"wakatime_api_key": user.WakatimeApiKey,
|
"wakatime_api_key": user.WakatimeApiKey,
|
||||||
"has_data": user.HasData,
|
"has_data": user.HasData,
|
||||||
"reset_token": user.ResetToken,
|
"reset_token": user.ResetToken,
|
||||||
|
"location": user.Location,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := r.db.Model(user).Updates(updateMap)
|
result := r.db.Model(user).Updates(updateMap)
|
||||||
|
@ -116,7 +116,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
from, to := utils.MustResolveIntervalRaw("today")
|
from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
|
||||||
|
|
||||||
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
|
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -74,7 +74,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, rangeFrom, rangeTo := utils.ResolveInterval(interval)
|
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
|
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
|
||||||
// negative value means no limit
|
// negative value means no limit
|
||||||
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
||||||
@ -118,7 +118,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
|
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
|
||||||
err, from, to := utils.ResolveInterval(interval)
|
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusBadRequest
|
return nil, err, http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
rangeParam = (*models.IntervalPast7Days)[0]
|
rangeParam = (*models.IntervalPast7Days)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam)
|
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte("invalid range"))
|
w.Write([]byte("invalid range"))
|
||||||
|
@ -87,12 +87,12 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
|||||||
var start, end time.Time
|
var start, end time.Time
|
||||||
if rangeParam != "" {
|
if rangeParam != "" {
|
||||||
// range param takes precedence
|
// range param takes precedence
|
||||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeParam); err == nil {
|
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ()); err == nil {
|
||||||
start, end = parsedFrom, parsedTo
|
start, end = parsedFrom, parsedTo
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
||||||
}
|
}
|
||||||
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(startParam); err == nil && startParam == endParam {
|
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, user.TZ()); err == nil && startParam == endParam {
|
||||||
// also accept start param to be a range param
|
// also accept start param to be a range param
|
||||||
start, end = parsedFrom, parsedTo
|
start, end = parsedFrom, parsedTo
|
||||||
} else {
|
} else {
|
||||||
|
@ -166,6 +166,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.Email = payload.Email
|
user.Email = payload.Email
|
||||||
|
user.Location = payload.Location
|
||||||
|
|
||||||
if _, err := h.userSrvc.Update(user); err != nil {
|
if _, err := h.userSrvc.Update(user); err != nil {
|
||||||
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
@ -8,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
|
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
|
||||||
|
user := middlewares.GetPrincipal(r)
|
||||||
summaryParams, err := utils.ParseSummaryParams(r)
|
summaryParams, err := utils.ParseSummaryParams(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusBadRequest
|
return nil, err, http.StatusBadRequest
|
||||||
@ -23,5 +25,8 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
|
|||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summary.FromTime = models.CustomTime(summary.FromTime.T().In(user.TZ()))
|
||||||
|
summary.ToTime = models.CustomTime(summary.ToTime.T().In(user.TZ()))
|
||||||
|
|
||||||
return summary, nil, http.StatusOK
|
return summary, nil, http.StatusOK
|
||||||
}
|
}
|
||||||
|
352
static/assets/timezones.js
Normal file
352
static/assets/timezones.js
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
// https://stackoverflow.com/a/54500197/3112139
|
||||||
|
|
||||||
|
const tzs = [
|
||||||
|
'Europe/Andorra',
|
||||||
|
'Asia/Dubai',
|
||||||
|
'Asia/Kabul',
|
||||||
|
'Europe/Tirane',
|
||||||
|
'Asia/Yerevan',
|
||||||
|
'Antarctica/Casey',
|
||||||
|
'Antarctica/Davis',
|
||||||
|
'Antarctica/DumontDUrville',
|
||||||
|
'Antarctica/Mawson',
|
||||||
|
'Antarctica/Palmer',
|
||||||
|
'Antarctica/Rothera',
|
||||||
|
'Antarctica/Syowa',
|
||||||
|
'Antarctica/Troll',
|
||||||
|
'Antarctica/Vostok',
|
||||||
|
'America/Argentina/Buenos_Aires',
|
||||||
|
'America/Argentina/Cordoba',
|
||||||
|
'America/Argentina/Salta',
|
||||||
|
'America/Argentina/Jujuy',
|
||||||
|
'America/Argentina/Tucuman',
|
||||||
|
'America/Argentina/Catamarca',
|
||||||
|
'America/Argentina/La_Rioja',
|
||||||
|
'America/Argentina/San_Juan',
|
||||||
|
'America/Argentina/Mendoza',
|
||||||
|
'America/Argentina/San_Luis',
|
||||||
|
'America/Argentina/Rio_Gallegos',
|
||||||
|
'America/Argentina/Ushuaia',
|
||||||
|
'Pacific/Pago_Pago',
|
||||||
|
'Europe/Vienna',
|
||||||
|
'Australia/Lord_Howe',
|
||||||
|
'Antarctica/Macquarie',
|
||||||
|
'Australia/Hobart',
|
||||||
|
'Australia/Currie',
|
||||||
|
'Australia/Melbourne',
|
||||||
|
'Australia/Sydney',
|
||||||
|
'Australia/Broken_Hill',
|
||||||
|
'Australia/Brisbane',
|
||||||
|
'Australia/Lindeman',
|
||||||
|
'Australia/Adelaide',
|
||||||
|
'Australia/Darwin',
|
||||||
|
'Australia/Perth',
|
||||||
|
'Australia/Eucla',
|
||||||
|
'Asia/Baku',
|
||||||
|
'America/Barbados',
|
||||||
|
'Asia/Dhaka',
|
||||||
|
'Europe/Brussels',
|
||||||
|
'Europe/Sofia',
|
||||||
|
'Atlantic/Bermuda',
|
||||||
|
'Asia/Brunei',
|
||||||
|
'America/La_Paz',
|
||||||
|
'America/Noronha',
|
||||||
|
'America/Belem',
|
||||||
|
'America/Fortaleza',
|
||||||
|
'America/Recife',
|
||||||
|
'America/Araguaina',
|
||||||
|
'America/Maceio',
|
||||||
|
'America/Bahia',
|
||||||
|
'America/Sao_Paulo',
|
||||||
|
'America/Campo_Grande',
|
||||||
|
'America/Cuiaba',
|
||||||
|
'America/Santarem',
|
||||||
|
'America/Porto_Velho',
|
||||||
|
'America/Boa_Vista',
|
||||||
|
'America/Manaus',
|
||||||
|
'America/Eirunepe',
|
||||||
|
'America/Rio_Branco',
|
||||||
|
'America/Nassau',
|
||||||
|
'Asia/Thimphu',
|
||||||
|
'Europe/Minsk',
|
||||||
|
'America/Belize',
|
||||||
|
'America/St_Johns',
|
||||||
|
'America/Halifax',
|
||||||
|
'America/Glace_Bay',
|
||||||
|
'America/Moncton',
|
||||||
|
'America/Goose_Bay',
|
||||||
|
'America/Blanc-Sablon',
|
||||||
|
'America/Toronto',
|
||||||
|
'America/Nipigon',
|
||||||
|
'America/Thunder_Bay',
|
||||||
|
'America/Iqaluit',
|
||||||
|
'America/Pangnirtung',
|
||||||
|
'America/Atikokan',
|
||||||
|
'America/Winnipeg',
|
||||||
|
'America/Rainy_River',
|
||||||
|
'America/Resolute',
|
||||||
|
'America/Rankin_Inlet',
|
||||||
|
'America/Regina',
|
||||||
|
'America/Swift_Current',
|
||||||
|
'America/Edmonton',
|
||||||
|
'America/Cambridge_Bay',
|
||||||
|
'America/Yellowknife',
|
||||||
|
'America/Inuvik',
|
||||||
|
'America/Creston',
|
||||||
|
'America/Dawson_Creek',
|
||||||
|
'America/Fort_Nelson',
|
||||||
|
'America/Vancouver',
|
||||||
|
'America/Whitehorse',
|
||||||
|
'America/Dawson',
|
||||||
|
'Indian/Cocos',
|
||||||
|
'Europe/Zurich',
|
||||||
|
'Africa/Abidjan',
|
||||||
|
'Pacific/Rarotonga',
|
||||||
|
'America/Santiago',
|
||||||
|
'America/Punta_Arenas',
|
||||||
|
'Pacific/Easter',
|
||||||
|
'Asia/Shanghai',
|
||||||
|
'Asia/Urumqi',
|
||||||
|
'America/Bogota',
|
||||||
|
'America/Costa_Rica',
|
||||||
|
'America/Havana',
|
||||||
|
'Atlantic/Cape_Verde',
|
||||||
|
'America/Curacao',
|
||||||
|
'Indian/Christmas',
|
||||||
|
'Asia/Nicosia',
|
||||||
|
'Asia/Famagusta',
|
||||||
|
'Europe/Prague',
|
||||||
|
'Europe/Berlin',
|
||||||
|
'Europe/Copenhagen',
|
||||||
|
'America/Santo_Domingo',
|
||||||
|
'Africa/Algiers',
|
||||||
|
'America/Guayaquil',
|
||||||
|
'Pacific/Galapagos',
|
||||||
|
'Europe/Tallinn',
|
||||||
|
'Africa/Cairo',
|
||||||
|
'Africa/El_Aaiun',
|
||||||
|
'Europe/Madrid',
|
||||||
|
'Africa/Ceuta',
|
||||||
|
'Atlantic/Canary',
|
||||||
|
'Europe/Helsinki',
|
||||||
|
'Pacific/Fiji',
|
||||||
|
'Atlantic/Stanley',
|
||||||
|
'Pacific/Chuuk',
|
||||||
|
'Pacific/Pohnpei',
|
||||||
|
'Pacific/Kosrae',
|
||||||
|
'Atlantic/Faroe',
|
||||||
|
'Europe/Paris',
|
||||||
|
'Europe/London',
|
||||||
|
'Asia/Tbilisi',
|
||||||
|
'America/Cayenne',
|
||||||
|
'Africa/Accra',
|
||||||
|
'Europe/Gibraltar',
|
||||||
|
'America/Godthab',
|
||||||
|
'America/Danmarkshavn',
|
||||||
|
'America/Scoresbysund',
|
||||||
|
'America/Thule',
|
||||||
|
'Europe/Athens',
|
||||||
|
'Atlantic/South_Georgia',
|
||||||
|
'America/Guatemala',
|
||||||
|
'Pacific/Guam',
|
||||||
|
'Africa/Bissau',
|
||||||
|
'America/Guyana',
|
||||||
|
'Asia/Hong_Kong',
|
||||||
|
'America/Tegucigalpa',
|
||||||
|
'America/Port-au-Prince',
|
||||||
|
'Europe/Budapest',
|
||||||
|
'Asia/Jakarta',
|
||||||
|
'Asia/Pontianak',
|
||||||
|
'Asia/Makassar',
|
||||||
|
'Asia/Jayapura',
|
||||||
|
'Europe/Dublin',
|
||||||
|
'Asia/Jerusalem',
|
||||||
|
'Asia/Kolkata',
|
||||||
|
'Indian/Chagos',
|
||||||
|
'Asia/Baghdad',
|
||||||
|
'Asia/Tehran',
|
||||||
|
'Atlantic/Reykjavik',
|
||||||
|
'Europe/Rome',
|
||||||
|
'America/Jamaica',
|
||||||
|
'Asia/Amman',
|
||||||
|
'Asia/Tokyo',
|
||||||
|
'Africa/Nairobi',
|
||||||
|
'Asia/Bishkek',
|
||||||
|
'Pacific/Tarawa',
|
||||||
|
'Pacific/Enderbury',
|
||||||
|
'Pacific/Kiritimati',
|
||||||
|
'Asia/Pyongyang',
|
||||||
|
'Asia/Seoul',
|
||||||
|
'Asia/Almaty',
|
||||||
|
'Asia/Qyzylorda',
|
||||||
|
'Asia/Qostanay',
|
||||||
|
'Asia/Aqtobe',
|
||||||
|
'Asia/Aqtau',
|
||||||
|
'Asia/Atyrau',
|
||||||
|
'Asia/Oral',
|
||||||
|
'Asia/Beirut',
|
||||||
|
'Asia/Colombo',
|
||||||
|
'Africa/Monrovia',
|
||||||
|
'Europe/Vilnius',
|
||||||
|
'Europe/Luxembourg',
|
||||||
|
'Europe/Riga',
|
||||||
|
'Africa/Tripoli',
|
||||||
|
'Africa/Casablanca',
|
||||||
|
'Europe/Monaco',
|
||||||
|
'Europe/Chisinau',
|
||||||
|
'Pacific/Majuro',
|
||||||
|
'Pacific/Kwajalein',
|
||||||
|
'Asia/Yangon',
|
||||||
|
'Asia/Ulaanbaatar',
|
||||||
|
'Asia/Hovd',
|
||||||
|
'Asia/Choibalsan',
|
||||||
|
'Asia/Macau',
|
||||||
|
'America/Martinique',
|
||||||
|
'Europe/Malta',
|
||||||
|
'Indian/Mauritius',
|
||||||
|
'Indian/Maldives',
|
||||||
|
'America/Mexico_City',
|
||||||
|
'America/Cancun',
|
||||||
|
'America/Merida',
|
||||||
|
'America/Monterrey',
|
||||||
|
'America/Matamoros',
|
||||||
|
'America/Mazatlan',
|
||||||
|
'America/Chihuahua',
|
||||||
|
'America/Ojinaga',
|
||||||
|
'America/Hermosillo',
|
||||||
|
'America/Tijuana',
|
||||||
|
'America/Bahia_Banderas',
|
||||||
|
'Asia/Kuala_Lumpur',
|
||||||
|
'Asia/Kuching',
|
||||||
|
'Africa/Maputo',
|
||||||
|
'Africa/Windhoek',
|
||||||
|
'Pacific/Noumea',
|
||||||
|
'Pacific/Norfolk',
|
||||||
|
'Africa/Lagos',
|
||||||
|
'America/Managua',
|
||||||
|
'Europe/Amsterdam',
|
||||||
|
'Europe/Oslo',
|
||||||
|
'Asia/Kathmandu',
|
||||||
|
'Pacific/Nauru',
|
||||||
|
'Pacific/Niue',
|
||||||
|
'Pacific/Auckland',
|
||||||
|
'Pacific/Chatham',
|
||||||
|
'America/Panama',
|
||||||
|
'America/Lima',
|
||||||
|
'Pacific/Tahiti',
|
||||||
|
'Pacific/Marquesas',
|
||||||
|
'Pacific/Gambier',
|
||||||
|
'Pacific/Port_Moresby',
|
||||||
|
'Pacific/Bougainville',
|
||||||
|
'Asia/Manila',
|
||||||
|
'Asia/Karachi',
|
||||||
|
'Europe/Warsaw',
|
||||||
|
'America/Miquelon',
|
||||||
|
'Pacific/Pitcairn',
|
||||||
|
'America/Puerto_Rico',
|
||||||
|
'Asia/Gaza',
|
||||||
|
'Asia/Hebron',
|
||||||
|
'Europe/Lisbon',
|
||||||
|
'Atlantic/Madeira',
|
||||||
|
'Atlantic/Azores',
|
||||||
|
'Pacific/Palau',
|
||||||
|
'America/Asuncion',
|
||||||
|
'Asia/Qatar',
|
||||||
|
'Indian/Reunion',
|
||||||
|
'Europe/Bucharest',
|
||||||
|
'Europe/Belgrade',
|
||||||
|
'Europe/Kaliningrad',
|
||||||
|
'Europe/Moscow',
|
||||||
|
'Europe/Simferopol',
|
||||||
|
'Europe/Kirov',
|
||||||
|
'Europe/Astrakhan',
|
||||||
|
'Europe/Volgograd',
|
||||||
|
'Europe/Saratov',
|
||||||
|
'Europe/Ulyanovsk',
|
||||||
|
'Europe/Samara',
|
||||||
|
'Asia/Yekaterinburg',
|
||||||
|
'Asia/Omsk',
|
||||||
|
'Asia/Novosibirsk',
|
||||||
|
'Asia/Barnaul',
|
||||||
|
'Asia/Tomsk',
|
||||||
|
'Asia/Novokuznetsk',
|
||||||
|
'Asia/Krasnoyarsk',
|
||||||
|
'Asia/Irkutsk',
|
||||||
|
'Asia/Chita',
|
||||||
|
'Asia/Yakutsk',
|
||||||
|
'Asia/Khandyga',
|
||||||
|
'Asia/Vladivostok',
|
||||||
|
'Asia/Ust-Nera',
|
||||||
|
'Asia/Magadan',
|
||||||
|
'Asia/Sakhalin',
|
||||||
|
'Asia/Srednekolymsk',
|
||||||
|
'Asia/Kamchatka',
|
||||||
|
'Asia/Anadyr',
|
||||||
|
'Asia/Riyadh',
|
||||||
|
'Pacific/Guadalcanal',
|
||||||
|
'Indian/Mahe',
|
||||||
|
'Africa/Khartoum',
|
||||||
|
'Europe/Stockholm',
|
||||||
|
'Asia/Singapore',
|
||||||
|
'America/Paramaribo',
|
||||||
|
'Africa/Juba',
|
||||||
|
'Africa/Sao_Tome',
|
||||||
|
'America/El_Salvador',
|
||||||
|
'Asia/Damascus',
|
||||||
|
'America/Grand_Turk',
|
||||||
|
'Africa/Ndjamena',
|
||||||
|
'Indian/Kerguelen',
|
||||||
|
'Asia/Bangkok',
|
||||||
|
'Asia/Dushanbe',
|
||||||
|
'Pacific/Fakaofo',
|
||||||
|
'Asia/Dili',
|
||||||
|
'Asia/Ashgabat',
|
||||||
|
'Africa/Tunis',
|
||||||
|
'Pacific/Tongatapu',
|
||||||
|
'Europe/Istanbul',
|
||||||
|
'America/Port_of_Spain',
|
||||||
|
'Pacific/Funafuti',
|
||||||
|
'Asia/Taipei',
|
||||||
|
'Europe/Kiev',
|
||||||
|
'Europe/Uzhgorod',
|
||||||
|
'Europe/Zaporozhye',
|
||||||
|
'Pacific/Wake',
|
||||||
|
'America/New_York',
|
||||||
|
'America/Detroit',
|
||||||
|
'America/Kentucky/Louisville',
|
||||||
|
'America/Kentucky/Monticello',
|
||||||
|
'America/Indiana/Indianapolis',
|
||||||
|
'America/Indiana/Vincennes',
|
||||||
|
'America/Indiana/Winamac',
|
||||||
|
'America/Indiana/Marengo',
|
||||||
|
'America/Indiana/Petersburg',
|
||||||
|
'America/Indiana/Vevay',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Indiana/Tell_City',
|
||||||
|
'America/Indiana/Knox',
|
||||||
|
'America/Menominee',
|
||||||
|
'America/North_Dakota/Center',
|
||||||
|
'America/North_Dakota/New_Salem',
|
||||||
|
'America/North_Dakota/Beulah',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Boise',
|
||||||
|
'America/Phoenix',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/Anchorage',
|
||||||
|
'America/Juneau',
|
||||||
|
'America/Sitka',
|
||||||
|
'America/Metlakatla',
|
||||||
|
'America/Yakutat',
|
||||||
|
'America/Nome',
|
||||||
|
'America/Adak',
|
||||||
|
'Pacific/Honolulu',
|
||||||
|
'America/Montevideo',
|
||||||
|
'Asia/Samarkand',
|
||||||
|
'Asia/Tashkent',
|
||||||
|
'America/Caracas',
|
||||||
|
'Asia/Ho_Chi_Minh',
|
||||||
|
'Pacific/Efate',
|
||||||
|
'Pacific/Wallis',
|
||||||
|
'Pacific/Apia',
|
||||||
|
'Africa/Johannesburg'
|
||||||
|
]
|
@ -7,12 +7,22 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseDate(date string) (time.Time, error) {
|
// ParseDateTimeTZ attempts to parse the given date string from multiple formats.
|
||||||
return time.Parse(config.SimpleDateFormat, date)
|
// First, a time-zoned date-time string (e.g. 2006-01-02T15:04:05+02:00) is tried
|
||||||
}
|
// Second, a non-time-zoned date-time string (e.g. 2006-01-02 15:04:05) is tried at the given zone
|
||||||
|
// Third, a non-time-zoned date string (e.g. 2006-01-02) is tried at the given zone
|
||||||
func ParseDateTime(date string) (time.Time, error) {
|
// Example:
|
||||||
return time.Parse(config.SimpleDateTimeFormat, date)
|
// - Server runs in CEST (UTC+2), requesting user lives in PDT (UTC-7).
|
||||||
|
// - 2021-04-25T10:30:00Z, 2021-04-25T3:30:00-0100 and 2021-04-25T12:30:00+0200 are equivalent, they represent the same point in time
|
||||||
|
// - When user requests non-time-zoned range (e.g. 2021-04-25T00:00:00), but has their time zone properly configured, this will resolve to 2021-04-25T09:00:00
|
||||||
|
func ParseDateTimeTZ(date string, tz *time.Location) (time.Time, error) {
|
||||||
|
if t, err := time.Parse(time.RFC3339, date); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if t, err := time.ParseInLocation(config.SimpleDateTimeFormat, date, tz); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
return time.ParseInLocation(config.SimpleDateFormat, date, tz)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatDate(date time.Time) string {
|
func FormatDate(date time.Time) string {
|
||||||
|
@ -5,33 +5,42 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartOfToday() time.Time {
|
|
||||||
return StartOfDay(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
func StartOfDay(date time.Time) time.Time {
|
func StartOfDay(date time.Time) time.Time {
|
||||||
return FloorDate(date)
|
return FloorDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartOfWeek() time.Time {
|
func StartOfToday(tz *time.Location) time.Time {
|
||||||
ref := time.Now()
|
return StartOfDay(FloorDate(time.Now().In(tz)))
|
||||||
year, week := ref.ISOWeek()
|
|
||||||
return firstDayOfISOWeek(year, week, ref.Location())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartOfMonth() time.Time {
|
func StartOfThisWeek(tz *time.Location) time.Time {
|
||||||
ref := time.Now()
|
return StartOfWeek(time.Now().In(tz))
|
||||||
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartOfYear() time.Time {
|
func StartOfWeek(date time.Time) time.Time {
|
||||||
ref := time.Now()
|
year, week := date.ISOWeek()
|
||||||
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
|
return firstDayOfISOWeek(year, week, date.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
// FloorDate rounds date down to the start of the day
|
func StartOfThisMonth(tz *time.Location) time.Time {
|
||||||
|
return StartOfMonth(time.Now().In(tz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartOfMonth(date time.Time) time.Time {
|
||||||
|
return time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartOfThisYear(tz *time.Location) time.Time {
|
||||||
|
return StartOfYear(time.Now().In(tz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartOfYear(date time.Time) time.Time {
|
||||||
|
return time.Date(date.Year(), time.January, 1, 0, 0, 0, 0, date.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloorDate rounds date down to the start of the day and keeps the time zone
|
||||||
func FloorDate(date time.Time) time.Time {
|
func FloorDate(date time.Time) time.Time {
|
||||||
return date.Truncate(24 * time.Hour)
|
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
// CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
|
// CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
|
||||||
@ -43,6 +52,20 @@ func CeilDate(date time.Time) time.Time {
|
|||||||
return floored.Add(24 * time.Hour)
|
return floored.Add(24 * time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLocation resets the time zone information of a date without converting it, i.e. 19:00 UTC will result in 19:00 CET, for instance
|
||||||
|
func SetLocation(date time.Time, tz *time.Location) time.Time {
|
||||||
|
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithOffset adds the time zone difference between Local and tz to a date, i.e. 19:00 UTC will result in 21:00 CET (or 22:00 CEST), for instance
|
||||||
|
func WithOffset(date time.Time, tz *time.Location) time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
_, localOffset := now.Zone()
|
||||||
|
_, targetOffset := now.In(tz).Zone()
|
||||||
|
dateTz := date.Add(time.Duration((targetOffset - localOffset) * int(time.Second)))
|
||||||
|
return time.Date(dateTz.Year(), dateTz.Month(), dateTz.Day(), dateTz.Hour(), dateTz.Minute(), dateTz.Second(), dateTz.Nanosecond(), dateTz.Location()).In(tz)
|
||||||
|
}
|
||||||
|
|
||||||
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
|
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
|
||||||
intervals := make([][]time.Time, 0)
|
intervals := make([][]time.Time, 0)
|
||||||
|
|
||||||
|
@ -16,51 +16,51 @@ func ParseInterval(interval string) (*models.IntervalKey, error) {
|
|||||||
return nil, errors.New("not a valid interval")
|
return nil, errors.New("not a valid interval")
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustResolveIntervalRaw(interval string) (from, to time.Time) {
|
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||||
_, from, to = ResolveIntervalRaw(interval)
|
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||||
return from, to
|
return from, to
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveIntervalRaw(interval string) (err error, from, to time.Time) {
|
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
||||||
parsed, err := ParseInterval(interval)
|
parsed, err := ParseInterval(interval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, time.Time{}, time.Time{}
|
return err, time.Time{}, time.Time{}
|
||||||
}
|
}
|
||||||
return ResolveInterval(parsed)
|
return ResolveIntervalTZ(parsed, tz)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveInterval(interval *models.IntervalKey) (err error, from, to time.Time) {
|
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||||
to = time.Now()
|
to = time.Now().In(tz)
|
||||||
|
|
||||||
switch interval {
|
switch interval {
|
||||||
case models.IntervalToday:
|
case models.IntervalToday:
|
||||||
from = StartOfToday()
|
from = StartOfToday(tz)
|
||||||
case models.IntervalYesterday:
|
case models.IntervalYesterday:
|
||||||
from = StartOfToday().Add(-24 * time.Hour)
|
from = StartOfToday(tz).Add(-24 * time.Hour)
|
||||||
to = StartOfToday()
|
to = StartOfToday(tz)
|
||||||
case models.IntervalThisWeek:
|
case models.IntervalThisWeek:
|
||||||
from = StartOfWeek()
|
from = StartOfThisWeek(tz)
|
||||||
case models.IntervalLastWeek:
|
case models.IntervalLastWeek:
|
||||||
from = StartOfWeek().AddDate(0, 0, -7)
|
from = StartOfThisWeek(tz).AddDate(0, 0, -7)
|
||||||
to = StartOfWeek()
|
to = StartOfThisWeek(tz)
|
||||||
case models.IntervalThisMonth:
|
case models.IntervalThisMonth:
|
||||||
from = StartOfMonth()
|
from = StartOfThisMonth(tz)
|
||||||
case models.IntervalLastMonth:
|
case models.IntervalLastMonth:
|
||||||
from = StartOfMonth().AddDate(0, -1, 0)
|
from = StartOfThisMonth(tz).AddDate(0, -1, 0)
|
||||||
to = StartOfMonth()
|
to = StartOfThisMonth(tz)
|
||||||
case models.IntervalThisYear:
|
case models.IntervalThisYear:
|
||||||
from = StartOfYear()
|
from = StartOfThisYear(tz)
|
||||||
case models.IntervalPast7Days:
|
case models.IntervalPast7Days:
|
||||||
from = StartOfToday().AddDate(0, 0, -7)
|
from = StartOfToday(tz).AddDate(0, 0, -7)
|
||||||
case models.IntervalPast7DaysYesterday:
|
case models.IntervalPast7DaysYesterday:
|
||||||
from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7)
|
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||||
to = StartOfToday().AddDate(0, 0, -1)
|
to = StartOfToday(tz).AddDate(0, 0, -1)
|
||||||
case models.IntervalPast14Days:
|
case models.IntervalPast14Days:
|
||||||
from = StartOfToday().AddDate(0, 0, -14)
|
from = StartOfToday(tz).AddDate(0, 0, -14)
|
||||||
case models.IntervalPast30Days:
|
case models.IntervalPast30Days:
|
||||||
from = StartOfToday().AddDate(0, 0, -30)
|
from = StartOfToday(tz).AddDate(0, 0, -30)
|
||||||
case models.IntervalPast12Months:
|
case models.IntervalPast12Months:
|
||||||
from = StartOfToday().AddDate(0, -12, 0)
|
from = StartOfToday(tz).AddDate(0, -12, 0)
|
||||||
case models.IntervalAny:
|
case models.IntervalAny:
|
||||||
from = time.Time{}
|
from = time.Time{}
|
||||||
default:
|
default:
|
||||||
@ -78,24 +78,18 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
|||||||
var from, to time.Time
|
var from, to time.Time
|
||||||
|
|
||||||
if interval := params.Get("interval"); interval != "" {
|
if interval := params.Get("interval"); interval != "" {
|
||||||
err, from, to = ResolveIntervalRaw(interval)
|
err, from, to = ResolveIntervalRawTZ(interval, user.TZ())
|
||||||
} else if start := params.Get("start"); start != "" {
|
} else if start := params.Get("start"); start != "" {
|
||||||
err, from, to = ResolveIntervalRaw(start)
|
err, from, to = ResolveIntervalRawTZ(start, user.TZ())
|
||||||
} else {
|
} else {
|
||||||
from, err = ParseDateTime(params.Get("from"))
|
from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
from, err = ParseDate(params.Get("from"))
|
return nil, errors.New("missing or invalid 'from' parameter")
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("missing 'from' parameter")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
to, err = ParseDateTime(params.Get("to"))
|
to, err = ParseDateTimeTZ(params.Get("to"), user.TZ())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
to, err = ParseDate(params.Get("to"))
|
return nil, errors.New("missing or invalid 'to' parameter")
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("missing 'to' parameter")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
{{ template "head.tpl.html" . }}
|
{{ template "head.tpl.html" . }}
|
||||||
|
<script src="assets/timezones.js"></script>
|
||||||
|
|
||||||
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||||
|
|
||||||
@ -37,25 +38,37 @@
|
|||||||
<details class="my-8 pb-8 border-b border-gray-700">
|
<details class="my-8 pb-8 border-b border-gray-700">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
id="email-heading">
|
id="preferences-heading">
|
||||||
Change E-Mail Address
|
Account Preferences
|
||||||
</h2>
|
</h2>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<form class="mt-10" action="" method="post">
|
<form class="mt-10" action="" method="post">
|
||||||
<input type="hidden" name="action" value="update_user">
|
<input type="hidden" name="action" value="update_user">
|
||||||
<div class="mb-8 flex justify-between items-center space-x-4">
|
<div class="mb-8 flex justify-between items-center space-x-4">
|
||||||
<label class="inline-block text-sm text-gray-500" for="password_old">E-Mail Address</label>
|
<label class="inline-block text-sm text-gray-500" for="select-timezone">Time Zone</label>
|
||||||
|
<select name="location" id="select-timezone"
|
||||||
|
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded flex-grow py-1 px-3 cursor-pointer">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-8 flex justify-between items-center space-x-4">
|
||||||
|
<label class="inline-block text-sm text-gray-500" for="email">E-Mail Address</label>
|
||||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded flex-grow py-1 px-3"
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded flex-grow py-1 px-3"
|
||||||
type="email" id="email"
|
type="email" id="email"
|
||||||
name="email" placeholder="Enter your e-mail address"
|
name="email" placeholder="Enter your e-mail address"
|
||||||
value="{{ .User.Email }}">
|
value="{{ .User.Email }}">
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 text-sm">E-Mail address is optional, but required for some features
|
||||||
|
that you cannot use else. Also, if you do not add an e-mail address, you will not be able to
|
||||||
|
reset your password in case you forget it.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm self-end">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-300 text-sm">E-Mail address is optional, but required for some features that you cannot use else. Also, if you do not add an e-mail address, you will not be able to reset your password in case you forget it.</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@ -70,7 +83,8 @@
|
|||||||
<form class="mt-10" action="" method="post">
|
<form class="mt-10" action="" method="post">
|
||||||
<input type="hidden" name="action" value="change_password">
|
<input type="hidden" name="action" value="change_password">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current
|
||||||
|
Password</label>
|
||||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
type="password" id="password_old"
|
type="password" id="password_old"
|
||||||
name="password_old" placeholder="Enter your old password" minlength="6" required>
|
name="password_old" placeholder="Enter your old password" minlength="6" required>
|
||||||
@ -82,13 +96,15 @@
|
|||||||
name="password_new" placeholder="Choose a password" minlength="6" required>
|
name="password_new" placeholder="Choose a password" minlength="6" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again
|
||||||
|
...</label>
|
||||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
type="password" id="password_repeat"
|
type="password" id="password_repeat"
|
||||||
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between float-right">
|
<div class="flex justify-between float-right">
|
||||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
<button type="submit"
|
||||||
|
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -240,10 +256,14 @@
|
|||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-300 text-sm mb-4 mt-6">Some features require public access to your data without authentication. This mainly includes <strong>Badges</strong> and the integration with <strong>GitHub Readme Stats</strong>, corresponding to these API endpoints:</p>
|
<p class="text-gray-300 text-sm mb-4 mt-6">Some features require public access to your data without
|
||||||
|
authentication. This mainly includes <strong>Badges</strong> and the integration with <strong>GitHub
|
||||||
|
Readme Stats</strong>, corresponding to these API endpoints:</p>
|
||||||
<ul class="list-disc list-inside text-gray-300">
|
<ul class="list-disc list-inside text-gray-300">
|
||||||
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/compat/shields/v1/{user}</span></li>
|
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/compat/shields/v1/{user}</span>
|
||||||
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/v1/users/{user}/stats/{range}</span></li>
|
</li>
|
||||||
|
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/v1/users/{user}/stats/{range}</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form action="" method="post" class="mt-8">
|
<form action="" method="post" class="mt-8">
|
||||||
@ -252,7 +272,8 @@
|
|||||||
<span class="mr-2">Publicly accessible data range:<br><span class="text-xs text-gray-500">(in days; 0 = not public, -1 = unlimited)</span></span>
|
<span class="mr-2">Publicly accessible data range:<br><span class="text-xs text-gray-500">(in days; 0 = not public, -1 = unlimited)</span></span>
|
||||||
<div>
|
<div>
|
||||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
style="width: 70px;" type="number" id="max_days" name="max_days" min="-1" required value="{{ .User.ShareDataMaxDays }}">
|
style="width: 70px;" type="number" id="max_days" name="max_days" min="-1" required
|
||||||
|
value="{{ .User.ShareDataMaxDays }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
@ -260,9 +281,14 @@
|
|||||||
<span class="mr-2">Share projects: </span>
|
<span class="mr-2">Share projects: </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_projects" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
<select autocomplete="off" name="share_projects"
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No</option>
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes</option>
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected
|
||||||
|
{{ end }}>No
|
||||||
|
</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end
|
||||||
|
}}>Yes
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -271,9 +297,14 @@
|
|||||||
<span class="mr-2">Share languages: </span>
|
<span class="mr-2">Share languages: </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_languages" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
<select autocomplete="off" name="share_languages"
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No</option>
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes</option>
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected
|
||||||
|
{{ end }}>No
|
||||||
|
</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{
|
||||||
|
end }}>Yes
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -282,9 +313,14 @@
|
|||||||
<span class="mr-2">Share editors: </span>
|
<span class="mr-2">Share editors: </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_editors" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
<select autocomplete="off" name="share_editors"
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No</option>
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes</option>
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{
|
||||||
|
end }}>No
|
||||||
|
</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end
|
||||||
|
}}>Yes
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -293,9 +329,14 @@
|
|||||||
<span class="mr-2">Share operating systems: </span>
|
<span class="mr-2">Share operating systems: </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_oss" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
<select autocomplete="off" name="share_oss"
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No</option>
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>Yes</option>
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end
|
||||||
|
}}>No
|
||||||
|
</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
|
||||||
|
Yes
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -304,15 +345,22 @@
|
|||||||
<span class="mr-2">Share machines: </span>
|
<span class="mr-2">Share machines: </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_machines" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
<select autocomplete="off" name="share_machines"
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No</option>
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes</option>
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected
|
||||||
|
{{ end }}>No
|
||||||
|
</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end
|
||||||
|
}}>Yes
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between float-right mt-4">
|
<div class="flex justify-between float-right mt-4">
|
||||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm" style="width: 100px;">
|
<button type="submit"
|
||||||
|
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm"
|
||||||
|
style="width: 100px;">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -394,7 +442,8 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-6">
|
<p class="mt-6">
|
||||||
<span class="font-semibold"><span class="iconify inline" data-icon="emojione-v1:backhand-index-pointing-right"></span> Please note:</span>
|
<span class="font-semibold"><span class="iconify inline"
|
||||||
|
data-icon="emojione-v1:backhand-index-pointing-right"></span> Please note:</span>
|
||||||
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
|
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
|
||||||
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
|
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
|
||||||
rel="noopener noreferrer">#94</a>) to be implemented.</span>
|
rel="noopener noreferrer">#94</a>) to be implemented.</span>
|
||||||
@ -442,7 +491,8 @@
|
|||||||
<p>You have the ability to create badges from your coding statistics using <a
|
<p>You have the ability to create badges from your coding statistics using <a
|
||||||
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
||||||
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
||||||
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public Data</a> setting.</p>
|
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public
|
||||||
|
Data</a> setting.</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -451,7 +501,10 @@
|
|||||||
GitHub Readme Stats
|
GitHub Readme Stats
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p class="mb-4">Wakapi intregrates with <a href="https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats" class="underline" target="_blank" rel="noopener noreferrer">GitHub Readme Stats</a> to generate fancy cards for you.</p>
|
<p class="mb-4">Wakapi intregrates with <a
|
||||||
|
href="https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats"
|
||||||
|
class="underline" target="_blank" rel="noopener noreferrer">GitHub Readme Stats</a> to
|
||||||
|
generate fancy cards for you.</p>
|
||||||
|
|
||||||
{{ if ne .User.ShareDataMaxDays 0 }}
|
{{ if ne .User.ShareDataMaxDays 0 }}
|
||||||
<div class="flex space-x-1">
|
<div class="flex space-x-1">
|
||||||
@ -459,9 +512,10 @@
|
|||||||
<span class="text-xs text-gray-500">(Only available on public instances, not on localhost)</span>
|
<span class="text-xs text-gray-500">(Only available on public instances, not on localhost)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col mb-4 mt-2">
|
<div class="flex flex-col mb-4 mt-2">
|
||||||
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact" class="with-url-src-no-scheme">
|
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
|
||||||
|
class="with-url-src-no-scheme">
|
||||||
<p class="mt-2"><strong>Source URL:</strong>
|
<p class="mt-2"><strong>Source URL:</strong>
|
||||||
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
|
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
|
||||||
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
|
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@ -583,6 +637,29 @@
|
|||||||
formImportWakatime.submit()
|
formImportWakatime.submit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Time zone stuff
|
||||||
|
|
||||||
|
const userTimeZone = {{ .User.Location }}
|
||||||
|
const userTzOffset = {{ .User.TZOffset.Hours }}
|
||||||
|
const selectTimezone = document.getElementById('select-timezone')
|
||||||
|
const createTzOption = (tz) => {
|
||||||
|
if (!tz) tz = 'Local'
|
||||||
|
const option = document.createElement('option')
|
||||||
|
option.setAttribute('value', tz)
|
||||||
|
option.innerText = tz
|
||||||
|
if (tz === userTimeZone) option.setAttribute('selected', 'true')
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOption = createTzOption('Local')
|
||||||
|
defaultOption.value = 'Local'
|
||||||
|
defaultOption.innerText = `Local server time (UTC+${userTzOffset})`
|
||||||
|
selectTimezone.appendChild(defaultOption)
|
||||||
|
|
||||||
|
tzs.sort()
|
||||||
|
.map(createTzOption)
|
||||||
|
.forEach(o => selectTimezone.appendChild(o))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{ template "footer.tpl.html" . }}
|
{{ template "footer.tpl.html" . }}
|
||||||
|
Loading…
Reference in New Issue
Block a user