diff --git a/models/user.go b/models/user.go index 01a7aeb..9a82dab 100644 --- a/models/user.go +++ b/models/user.go @@ -1,6 +1,9 @@ package models -import "regexp" +import ( + "regexp" + "time" +) func init() { mailRegex = regexp.MustCompile(MailPattern) @@ -10,6 +13,7 @@ type User struct { ID string `json:"id" gorm:"primary_key"` ApiKey string `json:"api_key" gorm:"unique"` Email string `json:"email" gorm:"index:idx_user_email; size:255"` + Location string `json:"location"` Password string `json:"-"` 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"` @@ -54,7 +58,8 @@ type CredentialsReset struct { } type UserDataUpdate struct { - Email string `schema:"email"` + Email string `schema:"email"` + Location string `schema:"location"` } type TimeByUser struct { @@ -67,6 +72,22 @@ type CountByUser struct { 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 { return ValidatePassword(c.PasswordNew) && c.PasswordNew == c.PasswordRepeat @@ -85,7 +106,7 @@ func (s *Signup) IsValid() bool { } func (r *UserDataUpdate) IsValid() bool { - return ValidateEmail(r.Email) + return ValidateEmail(r.Email) && ValidateTimezone(r.Location) } func ValidateUsername(username string) bool { @@ -99,3 +120,8 @@ func ValidatePassword(password string) bool { func ValidateEmail(email string) bool { return email == "" || mailRegex.Match([]byte(email)) } + +func ValidateTimezone(tz string) bool { + _, err := time.LoadLocation(tz) + return err == nil +} diff --git a/repositories/heartbeart.go b/repositories/heartbeart.go index 9e92198..bcccce7 100644 --- a/repositories/heartbeart.go +++ b/repositories/heartbeart.go @@ -54,8 +54,8 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User var heartbeats []*models.Heartbeat if err := r.db. Where(&models.Heartbeat{UserID: user.ID}). - Where("time >= ?", from). - Where("time < ?", to). + Where("time >= ?", from.Local()). + Where("time < ?", to.Local()). Order("time asc"). Find(&heartbeats).Error; err != nil { return nil, err @@ -126,7 +126,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun func (r *HeartbeatRepository) DeleteBefore(t time.Time) error { if err := r.db. - Where("time <= ?", t). + Where("time <= ?", t.Local()). Delete(models.Heartbeat{}).Error; err != nil { return err } diff --git a/repositories/summary.go b/repositories/summary.go index 603d24b..01dc61a 100644 --- a/repositories/summary.go +++ b/repositories/summary.go @@ -40,8 +40,8 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim var summaries []*models.Summary if err := r.db. Where(&models.Summary{UserID: user.ID}). - Where("from_time >= ?", from). - Where("to_time <= ?", to). + Where("from_time >= ?", from.Local()). + Where("to_time <= ?", to.Local()). Order("from_time asc"). Preload("Projects", "type = ?", models.SummaryProject). Preload("Languages", "type = ?", models.SummaryLanguage). diff --git a/repositories/user.go b/repositories/user.go index f8ab48d..92b975e 100644 --- a/repositories/user.go +++ b/repositories/user.go @@ -77,7 +77,7 @@ func (r *UserRepository) GetAll() ([]*models.User, error) { func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) { var users []*models.User if err := r.db. - Where("last_logged_in_at >= ?", t). + Where("last_logged_in_at >= ?", t.Local()). Find(&users).Error; err != nil { return nil, err } @@ -96,7 +96,7 @@ func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, erro if err := r.db. Select("user as id"). Table("(?) as q", subQuery1). - Where("time >= ?", t). + Where("time >= ?", t.Local()). Scan(&userIds).Error; err != nil { return nil, err } @@ -142,6 +142,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) { "wakatime_api_key": user.WakatimeApiKey, "has_data": user.HasData, "reset_token": user.ResetToken, + "location": user.Location, } result := r.db.Model(user).Updates(updateMap) diff --git a/routes/api/metrics.go b/routes/api/metrics.go index f2479af..7f0c61b 100644 --- a/routes/api/metrics.go +++ b/routes/api/metrics.go @@ -116,7 +116,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) 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) if err != nil { diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go index 1c7100a..0fce5cb 100644 --- a/routes/compat/shields/v1/badge.go +++ b/routes/compat/shields/v1/badge.go @@ -74,7 +74,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) { 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))) // negative value means no limit 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) { - err, from, to := utils.ResolveInterval(interval) + err, from, to := utils.ResolveIntervalTZ(interval, user.TZ()) if err != nil { return nil, err, http.StatusBadRequest } diff --git a/routes/compat/wakatime/v1/stats.go b/routes/compat/wakatime/v1/stats.go index 8197012..bdce385 100644 --- a/routes/compat/wakatime/v1/stats.go +++ b/routes/compat/wakatime/v1/stats.go @@ -62,7 +62,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { rangeParam = (*models.IntervalPast7Days)[0] } - err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam) + err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ()) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("invalid range")) diff --git a/routes/compat/wakatime/v1/summaries.go b/routes/compat/wakatime/v1/summaries.go index 3646245..bbfeabf 100644 --- a/routes/compat/wakatime/v1/summaries.go +++ b/routes/compat/wakatime/v1/summaries.go @@ -87,12 +87,12 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary var start, end time.Time if rangeParam != "" { // 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 } else { 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 start, end = parsedFrom, parsedTo } else { diff --git a/routes/settings.go b/routes/settings.go index b8e5d6e..0a3eac7 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -166,6 +166,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques } user.Email = payload.Email + user.Location = payload.Location if _, err := h.userSrvc.Update(user); err != nil { return http.StatusInternalServerError, "", conf.ErrInternalServerError diff --git a/routes/utils/summary_utils.go b/routes/utils/summary_utils.go index f17cb3b..8467229 100644 --- a/routes/utils/summary_utils.go +++ b/routes/utils/summary_utils.go @@ -1,6 +1,7 @@ package utils import ( + "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" @@ -8,6 +9,7 @@ import ( ) func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) { + user := middlewares.GetPrincipal(r) summaryParams, err := utils.ParseSummaryParams(r) if err != nil { return nil, err, http.StatusBadRequest @@ -23,5 +25,8 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ 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 } diff --git a/static/assets/timezones.js b/static/assets/timezones.js new file mode 100644 index 0000000..7c437f1 --- /dev/null +++ b/static/assets/timezones.js @@ -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' +] \ No newline at end of file diff --git a/utils/common.go b/utils/common.go index 8d0978b..94d3a82 100644 --- a/utils/common.go +++ b/utils/common.go @@ -7,12 +7,22 @@ import ( "time" ) -func ParseDate(date string) (time.Time, error) { - return time.Parse(config.SimpleDateFormat, date) -} - -func ParseDateTime(date string) (time.Time, error) { - return time.Parse(config.SimpleDateTimeFormat, date) +// ParseDateTimeTZ attempts to parse the given date string from multiple formats. +// 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 +// Example: +// - 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 { diff --git a/utils/date.go b/utils/date.go index 8e6817b..d47ff55 100644 --- a/utils/date.go +++ b/utils/date.go @@ -5,33 +5,42 @@ import ( "time" ) -func StartOfToday() time.Time { - return StartOfDay(time.Now()) -} - func StartOfDay(date time.Time) time.Time { return FloorDate(date) } -func StartOfWeek() time.Time { - ref := time.Now() - year, week := ref.ISOWeek() - return firstDayOfISOWeek(year, week, ref.Location()) +func StartOfToday(tz *time.Location) time.Time { + return StartOfDay(FloorDate(time.Now().In(tz))) } -func StartOfMonth() time.Time { - ref := time.Now() - return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location()) +func StartOfThisWeek(tz *time.Location) time.Time { + return StartOfWeek(time.Now().In(tz)) } -func StartOfYear() time.Time { - ref := time.Now() - return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location()) +func StartOfWeek(date time.Time) time.Time { + year, week := date.ISOWeek() + 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 { - 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) @@ -43,6 +52,20 @@ func CeilDate(date time.Time) time.Time { 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 { intervals := make([][]time.Time, 0) diff --git a/utils/summary.go b/utils/summary.go index 8d4ac21..7374ac0 100644 --- a/utils/summary.go +++ b/utils/summary.go @@ -16,51 +16,51 @@ func ParseInterval(interval string) (*models.IntervalKey, error) { return nil, errors.New("not a valid interval") } -func MustResolveIntervalRaw(interval string) (from, to time.Time) { - _, from, to = ResolveIntervalRaw(interval) +func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) { + _, from, to = ResolveIntervalRawTZ(interval, tz) 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) if err != nil { return err, time.Time{}, time.Time{} } - return ResolveInterval(parsed) + return ResolveIntervalTZ(parsed, tz) } -func ResolveInterval(interval *models.IntervalKey) (err error, from, to time.Time) { - to = time.Now() +func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) { + to = time.Now().In(tz) switch interval { case models.IntervalToday: - from = StartOfToday() + from = StartOfToday(tz) case models.IntervalYesterday: - from = StartOfToday().Add(-24 * time.Hour) - to = StartOfToday() + from = StartOfToday(tz).Add(-24 * time.Hour) + to = StartOfToday(tz) case models.IntervalThisWeek: - from = StartOfWeek() + from = StartOfThisWeek(tz) case models.IntervalLastWeek: - from = StartOfWeek().AddDate(0, 0, -7) - to = StartOfWeek() + from = StartOfThisWeek(tz).AddDate(0, 0, -7) + to = StartOfThisWeek(tz) case models.IntervalThisMonth: - from = StartOfMonth() + from = StartOfThisMonth(tz) case models.IntervalLastMonth: - from = StartOfMonth().AddDate(0, -1, 0) - to = StartOfMonth() + from = StartOfThisMonth(tz).AddDate(0, -1, 0) + to = StartOfThisMonth(tz) case models.IntervalThisYear: - from = StartOfYear() + from = StartOfThisYear(tz) case models.IntervalPast7Days: - from = StartOfToday().AddDate(0, 0, -7) + from = StartOfToday(tz).AddDate(0, 0, -7) case models.IntervalPast7DaysYesterday: - from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7) - to = StartOfToday().AddDate(0, 0, -1) + from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7) + to = StartOfToday(tz).AddDate(0, 0, -1) case models.IntervalPast14Days: - from = StartOfToday().AddDate(0, 0, -14) + from = StartOfToday(tz).AddDate(0, 0, -14) case models.IntervalPast30Days: - from = StartOfToday().AddDate(0, 0, -30) + from = StartOfToday(tz).AddDate(0, 0, -30) case models.IntervalPast12Months: - from = StartOfToday().AddDate(0, -12, 0) + from = StartOfToday(tz).AddDate(0, -12, 0) case models.IntervalAny: from = time.Time{} default: @@ -78,24 +78,18 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { var from, to time.Time 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 != "" { - err, from, to = ResolveIntervalRaw(start) + err, from, to = ResolveIntervalRawTZ(start, user.TZ()) } else { - from, err = ParseDateTime(params.Get("from")) + from, err = ParseDateTimeTZ(params.Get("from"), user.TZ()) if err != nil { - from, err = ParseDate(params.Get("from")) - if err != nil { - return nil, errors.New("missing 'from' parameter") - } + return nil, errors.New("missing or invalid 'from' parameter") } - to, err = ParseDateTime(params.Get("to")) + to, err = ParseDateTimeTZ(params.Get("to"), user.TZ()) if err != nil { - to, err = ParseDate(params.Get("to")) - if err != nil { - return nil, errors.New("missing 'to' parameter") - } + return nil, errors.New("missing or invalid 'to' parameter") } } diff --git a/views/settings.tpl.html b/views/settings.tpl.html index dffda10..a316343 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -2,6 +2,7 @@ {{ template "head.tpl.html" . }} + @@ -37,25 +38,37 @@

- Change E-Mail Address + id="preferences-heading"> + Account Preferences

- + + +
+
+ +
+
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. +
+ +
-
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.
@@ -70,7 +83,8 @@
- + @@ -82,13 +96,15 @@ name="password_new" placeholder="Choose a password" minlength="6" required>
- +
-
@@ -240,10 +256,14 @@
-

Some features require public access to your data without authentication. This mainly includes Badges and the integration with GitHub Readme Stats, corresponding to these API endpoints:

+

Some features require public access to your data without + authentication. This mainly includes Badges and the integration with GitHub + Readme Stats, corresponding to these API endpoints:

@@ -252,7 +272,8 @@ Publicly accessible data range:
(in days; 0 = not public, -1 = unlimited)
+ style="width: 70px;" type="number" id="max_days" name="max_days" min="-1" required + value="{{ .User.ShareDataMaxDays }}">
@@ -260,9 +281,14 @@ Share projects:
- + +
@@ -271,9 +297,14 @@ Share languages:
- + +
@@ -282,9 +313,14 @@ Share editors:
- + +
@@ -293,9 +329,14 @@ Share operating systems:
- + +
@@ -304,15 +345,22 @@ Share machines:
- + +
-
@@ -394,7 +442,8 @@

- Please note: + Please note: 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 (#94) to be implemented. @@ -442,7 +491,8 @@

You have the ability to create badges from your coding statistics using Shields.io. To do so, you need to grant public, unauthorized - access to the respective endpoint. See Public Data setting.

+ access to the respective endpoint. See Public + Data setting.

{{ end }} @@ -451,7 +501,10 @@ GitHub Readme Stats -

Wakapi intregrates with GitHub Readme Stats to generate fancy cards for you.

+

Wakapi intregrates with GitHub Readme Stats to + generate fancy cards for you.

{{ if ne .User.ShareDataMaxDays 0 }}
@@ -459,9 +512,10 @@ (Only available on public instances, not on localhost)
- +

Source URL: - + 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

@@ -583,6 +637,29 @@ 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)) {{ template "footer.tpl.html" . }}