refactor: time zone sensitivity (resolve #184)

This commit is contained in:
Ferdinand Mütsch 2021-04-25 14:15:18 +02:00
parent 26ef93c1af
commit 6973743f41
15 changed files with 594 additions and 105 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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).

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

@ -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"))

View File

@ -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 {

View File

@ -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

View File

@ -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
}

352
static/assets/timezones.js Normal file
View 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'
]

View File

@ -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 {

View File

@ -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)

View File

@ -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")
}
}

View File

@ -2,6 +2,7 @@
<html lang="en">
{{ 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">
@ -37,25 +38,37 @@
<details class="my-8 pb-8 border-b border-gray-700">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="email-heading">
Change E-Mail Address
id="preferences-heading">
Account Preferences
</h2>
</summary>
<div class="w-full">
<form class="mt-10" action="" method="post">
<input type="hidden" name="action" value="update_user">
<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"
type="email" id="email"
name="email" placeholder="Enter your e-mail address"
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"
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
</button>
</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>
</div>
</details>
@ -70,7 +83,8 @@
<form class="mt-10" action="" method="post">
<input type="hidden" name="action" value="change_password">
<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"
type="password" id="password_old"
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>
</div>
<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"
type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
</div>
<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
</button>
</div>
@ -240,10 +256,14 @@
</summary>
<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">
<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/v1/users/{user}/stats/{range}</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>
<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>
<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>
<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"
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 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>
</div>
<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">
<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 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">
<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>
</div>
</div>
@ -271,9 +297,14 @@
<span class="mr-2">Share languages: </span>
</div>
<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">
<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 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">
<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>
</div>
</div>
@ -282,9 +313,14 @@
<span class="mr-2">Share editors: </span>
</div>
<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">
<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 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">
<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>
</div>
</div>
@ -293,9 +329,14 @@
<span class="mr-2">Share operating systems: </span>
</div>
<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">
<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 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">
<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>
</div>
</div>
@ -304,15 +345,22 @@
<span class="mr-2">Share machines: </span>
</div>
<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">
<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 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">
<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>
</div>
</div>
<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
</button>
</div>
@ -394,7 +442,8 @@
</form>
<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
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
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
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
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 }}
</div>
@ -451,7 +501,10 @@
GitHub Readme Stats
</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 }}
<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>
</div>
<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>
<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
</span>
</p>
@ -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))
</script>
{{ template "footer.tpl.html" . }}