feat: add user preference for start of week

This commit is contained in:
Nazmus Sayad
2025-08-18 22:40:45 +06:00
parent 1c47ebf636
commit bfd137e634
18 changed files with 155 additions and 63 deletions

View File

@@ -2,9 +2,10 @@ package helpers
import (
"errors"
"time"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
func ParseInterval(interval string) (*models.IntervalKey, error) {
@@ -21,20 +22,20 @@ func MustParseInterval(interval string) *models.IntervalKey {
return key
}
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
_, from, to = ResolveIntervalRawTZ(interval, tz)
func MustResolveIntervalRawTZ(interval string, tz *time.Location, startOfWeek time.Weekday) (from, to time.Time) {
_, from, to = ResolveIntervalRawTZ(interval, tz, startOfWeek)
return from, to
}
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
func ResolveIntervalRawTZ(interval string, tz *time.Location, startOfWeek time.Weekday) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval)
if err != nil {
return err, time.Time{}, time.Time{}
}
return ResolveIntervalTZ(parsed, tz)
return ResolveIntervalTZ(parsed, tz, startOfWeek)
}
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location, startOfWeek time.Weekday) (err error, from, to time.Time) {
now := time.Now().In(tz)
to = now
@@ -47,10 +48,10 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
case models.IntervalPastDay:
from = now.Add(-24 * time.Hour)
case models.IntervalThisWeek:
from = utils.BeginOfThisWeek(tz)
from = utils.BeginOfThisWeek(tz, startOfWeek)
case models.IntervalLastWeek:
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
to = utils.BeginOfThisWeek(tz)
from = utils.BeginOfThisWeek(tz, startOfWeek).AddDate(0, 0, -7)
to = utils.BeginOfThisWeek(tz, startOfWeek)
case models.IntervalThisMonth:
from = utils.BeginOfThisMonth(tz)
case models.IntervalLastMonth:

View File

@@ -1,16 +1,17 @@
package helpers
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"testing"
"time"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
)
func TestResolveMaximumRange_Default(t *testing.T) {
for i := 1; i <= 367; i++ {
err1, maximumInterval := ResolveMaximumRange(i)
err2, from, to := ResolveIntervalTZ(maximumInterval, time.UTC)
err2, from, to := ResolveIntervalTZ(maximumInterval, time.UTC, time.Monday)
assert.Nil(t, err1)
assert.Nil(t, err2)

View File

@@ -2,9 +2,10 @@ package helpers
import (
"errors"
"github.com/muety/wakapi/models"
"net/http"
"time"
"github.com/muety/wakapi/models"
)
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
@@ -15,9 +16,9 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
var from, to time.Time
if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveIntervalRawTZ(interval, user.TZ())
err, from, to = ResolveIntervalRawTZ(interval, user.TZ(), user.StartOfWeekDay())
} else if start := params.Get("start"); start != "" {
err, from, to = ResolveIntervalRawTZ(start, user.TZ())
err, from, to = ResolveIntervalRawTZ(start, user.TZ(), user.StartOfWeekDay())
} else {
from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
if err != nil {

View File

@@ -0,0 +1,41 @@
package migrations
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20250818-add_start-of-week"
f := migrationFunc{
name: name,
background: true,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
// Check if column already exists
var count int64
if err := db.Raw("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'start_of_week'").Scan(&count).Error; err != nil {
// If pragma_table_info fails (e.g., MySQL/PostgreSQL), try a different approach
if err := db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'start_of_week'").Scan(&count).Error; err != nil {
// If that also fails, assume column doesn't exist and proceed
count = 0
}
}
// Only add column if it doesn't exist
if count == 0 {
if err := db.Exec("ALTER TABLE users ADD COLUMN start_of_week INTEGER DEFAULT 1").Error; err != nil {
return err
}
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -28,6 +28,7 @@ type User struct {
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
Location string `json:"location"`
StartOfWeek int `json:"start_of_week" gorm:"default:1"`
Password string `json:"-"`
CreatedAt CustomTime `swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` // filled by gorm, see https://gorm.io/docs/conventions.html#CreatedAt
LastLoggedInAt CustomTime `swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` // filled by gorm, see https://gorm.io/docs/conventions.html#CreatedAt
@@ -90,6 +91,7 @@ type CredentialsReset struct {
type UserDataUpdate struct {
Email string `schema:"email"`
Location string `schema:"location"`
StartOfWeek int `schema:"start_of_week"`
ReportsWeekly bool `schema:"reports_weekly"`
PublicLeaderboard bool `schema:"public_leaderboard"`
}
@@ -126,6 +128,14 @@ func (u *User) TZOffset() time.Duration {
return time.Duration(offset * int(time.Second))
}
// StartOfWeekDay returns the user's preferred start of week as time.Weekday
func (u *User) StartOfWeekDay() time.Weekday {
if u.StartOfWeek < 0 || u.StartOfWeek > 6 {
u.StartOfWeek = 1 // Default to Monday
}
return time.Weekday(u.StartOfWeek)
}
func (u *User) AvatarURL(urlTemplate string) string {
urlTemplate = strings.ReplaceAll(urlTemplate, "{username}", u.ID)
urlTemplate = strings.ReplaceAll(urlTemplate, "{email}", u.Email)
@@ -215,7 +225,7 @@ func (s *Signup) IsValid() bool {
}
func (r *UserDataUpdate) IsValid() bool {
return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
return ValidateEmail(r.Email) && ValidateTimezone(r.Location) && ValidateStartOfWeek(r.StartOfWeek)
}
func ValidateUsername(username string) bool {
@@ -241,3 +251,7 @@ func ValidateTimezone(tz string) bool {
_, err := time.LoadLocation(tz)
return err == nil
}
func ValidateStartOfWeek(startOfWeek int) bool {
return startOfWeek >= 0 && startOfWeek <= 6
}

View File

@@ -3,9 +3,10 @@ package repositories
import (
"errors"
"fmt"
"github.com/duke-git/lancet/v2/condition"
"time"
"github.com/duke-git/lancet/v2/condition"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"gorm.io/gorm"
@@ -144,6 +145,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"has_data": user.HasData,
"reset_token": user.ResetToken,
"location": user.Location,
"start_of_week": user.StartOfWeek,
"reports_weekly": user.ReportsWeekly,
"public_leaderboard": user.PublicLeaderboard,
"subscribed_until": user.SubscribedUntil,

View File

@@ -2,6 +2,13 @@ package api
import (
"errors"
"log/slog"
"net/http"
"runtime"
"sort"
"sync"
"time"
"github.com/alitto/pond/v2"
"github.com/go-chi/chi/v5"
conf "github.com/muety/wakapi/config"
@@ -13,12 +20,6 @@ import (
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"log/slog"
"net/http"
"runtime"
"sort"
"sync"
"time"
)
const (
@@ -145,7 +146,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
return nil, err
}
from, to := helpers.MustResolveIntervalRawTZ("today", user.TZ())
from, to := helpers.MustResolveIntervalRawTZ("today", user.TZ(), user.StartOfWeekDay())
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, nil, nil, false)
if err != nil {
@@ -455,7 +456,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
// Get per-user total activity
_, from, to := helpers.ResolveIntervalTZ(models.IntervalAny, time.Local)
_, from, to := helpers.ResolveIntervalTZ(models.IntervalAny, time.Local, time.Monday)
to = to.Truncate(time.Hour)
wp := pond.NewPool(utils.HalfCPUs())

View File

@@ -2,12 +2,13 @@ package v1
import (
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models/types"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
"time"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@@ -88,7 +89,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
}
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey, filters *models.Filters) (*models.Summary, error, int) {
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ(), user.StartOfWeekDay())
if err != nil {
return nil, err, http.StatusBadRequest
}

View File

@@ -1,16 +1,17 @@
package v1
import (
"math"
"net/http"
"strings"
"time"
"github.com/duke-git/lancet/v2/slice"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"math"
"net/http"
"strings"
"time"
conf "github.com/muety/wakapi/config"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
@@ -120,7 +121,7 @@ func (h *LeadersHandler) buildViewModel(globalLeaderboard, languageLeaderboard m
totalUsers, _ := h.leaderboardSrvc.CountUsers(true)
totalPages := int(totalUsers/int64(pageParams.PageSize) + 1)
_, from, to := helpers.ResolveIntervalTZ(interval, time.UTC)
_, from, to := helpers.ResolveIntervalTZ(interval, time.UTC, time.Monday)
numDays := len(utils.SplitRangeByDays(from, to))
vm := &v1.LeadersViewModel{

View File

@@ -87,7 +87,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
}
}
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ(), requestedUser.StartOfWeekDay())
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))

View File

@@ -1,11 +1,12 @@
package v1
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models/types"
"net/http"
"time"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
@@ -63,7 +64,7 @@ func (h *StatusBarHandler) Get(w http.ResponseWriter, r *http.Request) {
rangeParam = (*models.IntervalToday)[0]
}
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, user.TZ())
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, user.TZ(), user.StartOfWeekDay())
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))

View File

@@ -2,13 +2,14 @@ package v1
import (
"errors"
"github.com/duke-git/lancet/v2/datetime"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
"net/http"
"strings"
"time"
"github.com/duke-git/lancet/v2/datetime"
"github.com/go-chi/chi/v5"
"github.com/muety/wakapi/helpers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
@@ -84,6 +85,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request, user *models.User)
rangeParam, startParam, endParam, tzParam := params.Get("range"), params.Get("start"), params.Get("end"), params.Get("timezone")
timezone := user.TZ()
startOfWeek := user.StartOfWeekDay()
if tzParam != "" {
if tz, err := time.LoadLocation(tzParam); err == nil {
timezone = tz
@@ -93,12 +95,12 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request, user *models.User)
var start, end time.Time
if rangeParam != "" {
// range param takes precedence
if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(rangeParam, timezone, startOfWeek); err == nil {
start, end = parsedFrom, parsedTo
} else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
}
} else if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
} else if err, parsedFrom, parsedTo := helpers.ResolveIntervalRawTZ(startParam, timezone, startOfWeek); err == nil && startParam == endParam {
// also accept start param to be a range param
start, end = parsedFrom, parsedTo
} else {

View File

@@ -3,10 +3,6 @@ package routes
import (
"encoding/base64"
"fmt"
"github.com/duke-git/lancet/v2/condition"
"github.com/go-chi/chi/v5"
"github.com/gofrs/uuid/v5"
"github.com/muety/wakapi/helpers"
"net/http"
"net/url"
"sort"
@@ -14,6 +10,13 @@ import (
"strings"
"time"
"github.com/duke-git/lancet/v2/condition"
"github.com/go-chi/chi/v5"
"github.com/gofrs/uuid/v5"
"github.com/muety/wakapi/helpers"
"log/slog"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/gorilla/schema"
conf "github.com/muety/wakapi/config"
@@ -24,7 +27,6 @@ import (
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/services/imports"
"github.com/muety/wakapi/utils"
"log/slog"
)
const criticalError = "a critical error has occurred, sorry"
@@ -220,6 +222,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
user.Email = payload.Email
user.Location = payload.Location
user.StartOfWeek = payload.StartOfWeek
user.ReportsWeekly = payload.ReportsWeekly
user.PublicLeaderboard = payload.PublicLeaderboard

View File

@@ -2,9 +2,10 @@ package utils
import (
"errors"
"regexp"
"github.com/muety/wakapi/helpers"
"github.com/muety/wakapi/models"
"regexp"
)
const (
@@ -37,7 +38,7 @@ func GetBadgeParams(reqPath string, authorizedUser, requestedUser *models.User)
}
}
_, rangeFrom, rangeTo := helpers.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
_, rangeFrom, rangeTo := helpers.ResolveIntervalTZ(intervalKey, requestedUser.TZ(), requestedUser.StartOfWeekDay())
interval := &models.KeyedInterval{
Interval: models.Interval{Start: rangeFrom, End: rangeTo},
Key: intervalKey,

View File

@@ -5,6 +5,10 @@ import (
_ "embed"
"errors"
"fmt"
"math"
"sync"
"time"
svg "github.com/ajstarks/svgo/float"
"github.com/alitto/pond/v2"
"github.com/duke-git/lancet/v2/condition"
@@ -14,9 +18,6 @@ import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"math"
"sync"
"time"
)
const (
@@ -67,7 +68,8 @@ func (s *ActivityService) GetChart(user *models.User, interval *models.IntervalK
}
func (s *ActivityService) getChartPastYear(user *models.User, darkTheme, hideAttribution bool) (string, error) {
err, from, to := helpers.ResolveIntervalTZ(models.IntervalPast12Months, user.TZ())
err, from, to := helpers.ResolveIntervalTZ(models.IntervalPast12Months, user.TZ(), user.StartOfWeekDay())
// TODO: I am not sure if we have to handle startOfWeekDay here, but it seems like we do not need to.
from = datetime.BeginOfWeek(from, time.Monday)
if err != nil {
return "", err

View File

@@ -2,6 +2,12 @@ package services
import (
"fmt"
"log/slog"
"reflect"
"strconv"
"strings"
"time"
"github.com/leandro-lugaresi/hub"
"github.com/muety/artifex/v2"
"github.com/muety/wakapi/config"
@@ -10,11 +16,6 @@ import (
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"log/slog"
"reflect"
"strconv"
"strings"
"time"
)
type LeaderboardService struct {
@@ -225,7 +226,7 @@ func (srv *LeaderboardService) GetAggregatedByIntervalAndUser(interval *models.I
}
func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) {
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ(), user.StartOfWeekDay())
if err != nil {
return nil, err
}
@@ -247,7 +248,7 @@ func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *model
}
func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, interval *models.IntervalKey, by uint8) ([]*models.LeaderboardItem, error) {
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ())
err, from, to := helpers.ResolveIntervalTZ(interval, user.TZ(), user.StartOfWeekDay())
if err != nil {
return nil, err
}

View File

@@ -1,9 +1,10 @@
package utils
import (
"github.com/duke-git/lancet/v2/datetime"
"strings"
"time"
"github.com/duke-git/lancet/v2/datetime"
)
func MustParseTime(layout, value string) time.Time {
@@ -15,8 +16,8 @@ func BeginOfToday(tz *time.Location) time.Time {
return datetime.BeginOfDay(time.Now().In(tz))
}
func BeginOfThisWeek(tz *time.Location) time.Time {
return datetime.BeginOfWeek(time.Now().In(tz), time.Monday)
func BeginOfThisWeek(tz *time.Location, startOfWeek time.Weekday) time.Time {
return datetime.BeginOfWeek(time.Now().In(tz), startOfWeek)
}
func BeginOfThisMonth(tz *time.Location) time.Time {

View File

@@ -76,6 +76,24 @@
</div>
</div>
<div class="flex mb-8">
<div class="w-1/2 mr-4 inline-block">
<label class="font-semibold text-gray-300" for="select-start-of-week">Start of Week</label>
<span class="block text-sm text-gray-600">Choose which day your week starts on. This affects "This Week" and "Last Week" intervals.</span>
</div>
<div class="w-1/2 ml-4">
<select name="start_of_week" id="select-start-of-week" class="select-default">
<option value="0" {{ if eq .User.StartOfWeek 0 }}selected{{ end }}>Sunday</option>
<option value="1" {{ if eq .User.StartOfWeek 1 }}selected{{ end }}>Monday</option>
<option value="2" {{ if eq .User.StartOfWeek 2 }}selected{{ end }}>Tuesday</option>
<option value="3" {{ if eq .User.StartOfWeek 3 }}selected{{ end }}>Wednesday</option>
<option value="4" {{ if eq .User.StartOfWeek 4 }}selected{{ end }}>Thursday</option>
<option value="5" {{ if eq .User.StartOfWeek 5 }}selected{{ end }}>Friday</option>
<option value="6" {{ if eq .User.StartOfWeek 6 }}selected{{ end }}>Saturday</option>
</select>
</div>
</div>
<div class="flex mb-8">
<div class="w-1/2 mr-4 inline-block">
<label class="font-semibold text-gray-300" for="email">E-Mail Address</label>