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 c142b525a4
15 changed files with 594 additions and 105 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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