mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
refactor: time zone sensitivity (resolve #184)
This commit is contained in:
parent
26ef93c1af
commit
c142b525a4
@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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).
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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"))
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
352
static/assets/timezones.js
Normal file
@ -0,0 +1,352 @@
|
||||
// https://stackoverflow.com/a/54500197/3112139
|
||||
|
||||
const tzs = [
|
||||
'Europe/Andorra',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kabul',
|
||||
'Europe/Tirane',
|
||||
'Asia/Yerevan',
|
||||
'Antarctica/Casey',
|
||||
'Antarctica/Davis',
|
||||
'Antarctica/DumontDUrville',
|
||||
'Antarctica/Mawson',
|
||||
'Antarctica/Palmer',
|
||||
'Antarctica/Rothera',
|
||||
'Antarctica/Syowa',
|
||||
'Antarctica/Troll',
|
||||
'Antarctica/Vostok',
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Argentina/Cordoba',
|
||||
'America/Argentina/Salta',
|
||||
'America/Argentina/Jujuy',
|
||||
'America/Argentina/Tucuman',
|
||||
'America/Argentina/Catamarca',
|
||||
'America/Argentina/La_Rioja',
|
||||
'America/Argentina/San_Juan',
|
||||
'America/Argentina/Mendoza',
|
||||
'America/Argentina/San_Luis',
|
||||
'America/Argentina/Rio_Gallegos',
|
||||
'America/Argentina/Ushuaia',
|
||||
'Pacific/Pago_Pago',
|
||||
'Europe/Vienna',
|
||||
'Australia/Lord_Howe',
|
||||
'Antarctica/Macquarie',
|
||||
'Australia/Hobart',
|
||||
'Australia/Currie',
|
||||
'Australia/Melbourne',
|
||||
'Australia/Sydney',
|
||||
'Australia/Broken_Hill',
|
||||
'Australia/Brisbane',
|
||||
'Australia/Lindeman',
|
||||
'Australia/Adelaide',
|
||||
'Australia/Darwin',
|
||||
'Australia/Perth',
|
||||
'Australia/Eucla',
|
||||
'Asia/Baku',
|
||||
'America/Barbados',
|
||||
'Asia/Dhaka',
|
||||
'Europe/Brussels',
|
||||
'Europe/Sofia',
|
||||
'Atlantic/Bermuda',
|
||||
'Asia/Brunei',
|
||||
'America/La_Paz',
|
||||
'America/Noronha',
|
||||
'America/Belem',
|
||||
'America/Fortaleza',
|
||||
'America/Recife',
|
||||
'America/Araguaina',
|
||||
'America/Maceio',
|
||||
'America/Bahia',
|
||||
'America/Sao_Paulo',
|
||||
'America/Campo_Grande',
|
||||
'America/Cuiaba',
|
||||
'America/Santarem',
|
||||
'America/Porto_Velho',
|
||||
'America/Boa_Vista',
|
||||
'America/Manaus',
|
||||
'America/Eirunepe',
|
||||
'America/Rio_Branco',
|
||||
'America/Nassau',
|
||||
'Asia/Thimphu',
|
||||
'Europe/Minsk',
|
||||
'America/Belize',
|
||||
'America/St_Johns',
|
||||
'America/Halifax',
|
||||
'America/Glace_Bay',
|
||||
'America/Moncton',
|
||||
'America/Goose_Bay',
|
||||
'America/Blanc-Sablon',
|
||||
'America/Toronto',
|
||||
'America/Nipigon',
|
||||
'America/Thunder_Bay',
|
||||
'America/Iqaluit',
|
||||
'America/Pangnirtung',
|
||||
'America/Atikokan',
|
||||
'America/Winnipeg',
|
||||
'America/Rainy_River',
|
||||
'America/Resolute',
|
||||
'America/Rankin_Inlet',
|
||||
'America/Regina',
|
||||
'America/Swift_Current',
|
||||
'America/Edmonton',
|
||||
'America/Cambridge_Bay',
|
||||
'America/Yellowknife',
|
||||
'America/Inuvik',
|
||||
'America/Creston',
|
||||
'America/Dawson_Creek',
|
||||
'America/Fort_Nelson',
|
||||
'America/Vancouver',
|
||||
'America/Whitehorse',
|
||||
'America/Dawson',
|
||||
'Indian/Cocos',
|
||||
'Europe/Zurich',
|
||||
'Africa/Abidjan',
|
||||
'Pacific/Rarotonga',
|
||||
'America/Santiago',
|
||||
'America/Punta_Arenas',
|
||||
'Pacific/Easter',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Urumqi',
|
||||
'America/Bogota',
|
||||
'America/Costa_Rica',
|
||||
'America/Havana',
|
||||
'Atlantic/Cape_Verde',
|
||||
'America/Curacao',
|
||||
'Indian/Christmas',
|
||||
'Asia/Nicosia',
|
||||
'Asia/Famagusta',
|
||||
'Europe/Prague',
|
||||
'Europe/Berlin',
|
||||
'Europe/Copenhagen',
|
||||
'America/Santo_Domingo',
|
||||
'Africa/Algiers',
|
||||
'America/Guayaquil',
|
||||
'Pacific/Galapagos',
|
||||
'Europe/Tallinn',
|
||||
'Africa/Cairo',
|
||||
'Africa/El_Aaiun',
|
||||
'Europe/Madrid',
|
||||
'Africa/Ceuta',
|
||||
'Atlantic/Canary',
|
||||
'Europe/Helsinki',
|
||||
'Pacific/Fiji',
|
||||
'Atlantic/Stanley',
|
||||
'Pacific/Chuuk',
|
||||
'Pacific/Pohnpei',
|
||||
'Pacific/Kosrae',
|
||||
'Atlantic/Faroe',
|
||||
'Europe/Paris',
|
||||
'Europe/London',
|
||||
'Asia/Tbilisi',
|
||||
'America/Cayenne',
|
||||
'Africa/Accra',
|
||||
'Europe/Gibraltar',
|
||||
'America/Godthab',
|
||||
'America/Danmarkshavn',
|
||||
'America/Scoresbysund',
|
||||
'America/Thule',
|
||||
'Europe/Athens',
|
||||
'Atlantic/South_Georgia',
|
||||
'America/Guatemala',
|
||||
'Pacific/Guam',
|
||||
'Africa/Bissau',
|
||||
'America/Guyana',
|
||||
'Asia/Hong_Kong',
|
||||
'America/Tegucigalpa',
|
||||
'America/Port-au-Prince',
|
||||
'Europe/Budapest',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Pontianak',
|
||||
'Asia/Makassar',
|
||||
'Asia/Jayapura',
|
||||
'Europe/Dublin',
|
||||
'Asia/Jerusalem',
|
||||
'Asia/Kolkata',
|
||||
'Indian/Chagos',
|
||||
'Asia/Baghdad',
|
||||
'Asia/Tehran',
|
||||
'Atlantic/Reykjavik',
|
||||
'Europe/Rome',
|
||||
'America/Jamaica',
|
||||
'Asia/Amman',
|
||||
'Asia/Tokyo',
|
||||
'Africa/Nairobi',
|
||||
'Asia/Bishkek',
|
||||
'Pacific/Tarawa',
|
||||
'Pacific/Enderbury',
|
||||
'Pacific/Kiritimati',
|
||||
'Asia/Pyongyang',
|
||||
'Asia/Seoul',
|
||||
'Asia/Almaty',
|
||||
'Asia/Qyzylorda',
|
||||
'Asia/Qostanay',
|
||||
'Asia/Aqtobe',
|
||||
'Asia/Aqtau',
|
||||
'Asia/Atyrau',
|
||||
'Asia/Oral',
|
||||
'Asia/Beirut',
|
||||
'Asia/Colombo',
|
||||
'Africa/Monrovia',
|
||||
'Europe/Vilnius',
|
||||
'Europe/Luxembourg',
|
||||
'Europe/Riga',
|
||||
'Africa/Tripoli',
|
||||
'Africa/Casablanca',
|
||||
'Europe/Monaco',
|
||||
'Europe/Chisinau',
|
||||
'Pacific/Majuro',
|
||||
'Pacific/Kwajalein',
|
||||
'Asia/Yangon',
|
||||
'Asia/Ulaanbaatar',
|
||||
'Asia/Hovd',
|
||||
'Asia/Choibalsan',
|
||||
'Asia/Macau',
|
||||
'America/Martinique',
|
||||
'Europe/Malta',
|
||||
'Indian/Mauritius',
|
||||
'Indian/Maldives',
|
||||
'America/Mexico_City',
|
||||
'America/Cancun',
|
||||
'America/Merida',
|
||||
'America/Monterrey',
|
||||
'America/Matamoros',
|
||||
'America/Mazatlan',
|
||||
'America/Chihuahua',
|
||||
'America/Ojinaga',
|
||||
'America/Hermosillo',
|
||||
'America/Tijuana',
|
||||
'America/Bahia_Banderas',
|
||||
'Asia/Kuala_Lumpur',
|
||||
'Asia/Kuching',
|
||||
'Africa/Maputo',
|
||||
'Africa/Windhoek',
|
||||
'Pacific/Noumea',
|
||||
'Pacific/Norfolk',
|
||||
'Africa/Lagos',
|
||||
'America/Managua',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Oslo',
|
||||
'Asia/Kathmandu',
|
||||
'Pacific/Nauru',
|
||||
'Pacific/Niue',
|
||||
'Pacific/Auckland',
|
||||
'Pacific/Chatham',
|
||||
'America/Panama',
|
||||
'America/Lima',
|
||||
'Pacific/Tahiti',
|
||||
'Pacific/Marquesas',
|
||||
'Pacific/Gambier',
|
||||
'Pacific/Port_Moresby',
|
||||
'Pacific/Bougainville',
|
||||
'Asia/Manila',
|
||||
'Asia/Karachi',
|
||||
'Europe/Warsaw',
|
||||
'America/Miquelon',
|
||||
'Pacific/Pitcairn',
|
||||
'America/Puerto_Rico',
|
||||
'Asia/Gaza',
|
||||
'Asia/Hebron',
|
||||
'Europe/Lisbon',
|
||||
'Atlantic/Madeira',
|
||||
'Atlantic/Azores',
|
||||
'Pacific/Palau',
|
||||
'America/Asuncion',
|
||||
'Asia/Qatar',
|
||||
'Indian/Reunion',
|
||||
'Europe/Bucharest',
|
||||
'Europe/Belgrade',
|
||||
'Europe/Kaliningrad',
|
||||
'Europe/Moscow',
|
||||
'Europe/Simferopol',
|
||||
'Europe/Kirov',
|
||||
'Europe/Astrakhan',
|
||||
'Europe/Volgograd',
|
||||
'Europe/Saratov',
|
||||
'Europe/Ulyanovsk',
|
||||
'Europe/Samara',
|
||||
'Asia/Yekaterinburg',
|
||||
'Asia/Omsk',
|
||||
'Asia/Novosibirsk',
|
||||
'Asia/Barnaul',
|
||||
'Asia/Tomsk',
|
||||
'Asia/Novokuznetsk',
|
||||
'Asia/Krasnoyarsk',
|
||||
'Asia/Irkutsk',
|
||||
'Asia/Chita',
|
||||
'Asia/Yakutsk',
|
||||
'Asia/Khandyga',
|
||||
'Asia/Vladivostok',
|
||||
'Asia/Ust-Nera',
|
||||
'Asia/Magadan',
|
||||
'Asia/Sakhalin',
|
||||
'Asia/Srednekolymsk',
|
||||
'Asia/Kamchatka',
|
||||
'Asia/Anadyr',
|
||||
'Asia/Riyadh',
|
||||
'Pacific/Guadalcanal',
|
||||
'Indian/Mahe',
|
||||
'Africa/Khartoum',
|
||||
'Europe/Stockholm',
|
||||
'Asia/Singapore',
|
||||
'America/Paramaribo',
|
||||
'Africa/Juba',
|
||||
'Africa/Sao_Tome',
|
||||
'America/El_Salvador',
|
||||
'Asia/Damascus',
|
||||
'America/Grand_Turk',
|
||||
'Africa/Ndjamena',
|
||||
'Indian/Kerguelen',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Dushanbe',
|
||||
'Pacific/Fakaofo',
|
||||
'Asia/Dili',
|
||||
'Asia/Ashgabat',
|
||||
'Africa/Tunis',
|
||||
'Pacific/Tongatapu',
|
||||
'Europe/Istanbul',
|
||||
'America/Port_of_Spain',
|
||||
'Pacific/Funafuti',
|
||||
'Asia/Taipei',
|
||||
'Europe/Kiev',
|
||||
'Europe/Uzhgorod',
|
||||
'Europe/Zaporozhye',
|
||||
'Pacific/Wake',
|
||||
'America/New_York',
|
||||
'America/Detroit',
|
||||
'America/Kentucky/Louisville',
|
||||
'America/Kentucky/Monticello',
|
||||
'America/Indiana/Indianapolis',
|
||||
'America/Indiana/Vincennes',
|
||||
'America/Indiana/Winamac',
|
||||
'America/Indiana/Marengo',
|
||||
'America/Indiana/Petersburg',
|
||||
'America/Indiana/Vevay',
|
||||
'America/Chicago',
|
||||
'America/Indiana/Tell_City',
|
||||
'America/Indiana/Knox',
|
||||
'America/Menominee',
|
||||
'America/North_Dakota/Center',
|
||||
'America/North_Dakota/New_Salem',
|
||||
'America/North_Dakota/Beulah',
|
||||
'America/Denver',
|
||||
'America/Boise',
|
||||
'America/Phoenix',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'America/Juneau',
|
||||
'America/Sitka',
|
||||
'America/Metlakatla',
|
||||
'America/Yakutat',
|
||||
'America/Nome',
|
||||
'America/Adak',
|
||||
'Pacific/Honolulu',
|
||||
'America/Montevideo',
|
||||
'Asia/Samarkand',
|
||||
'Asia/Tashkent',
|
||||
'America/Caracas',
|
||||
'Asia/Ho_Chi_Minh',
|
||||
'Pacific/Efate',
|
||||
'Pacific/Wallis',
|
||||
'Pacific/Apia',
|
||||
'Africa/Johannesburg'
|
||||
]
|
@ -7,12 +7,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
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 {
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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" . }}
|
||||
|
Loading…
Reference in New Issue
Block a user