feat: set timezone user level

This commit is contained in:
juancwu 2026-03-03 12:29:41 +00:00
commit 945069052f
11 changed files with 261 additions and 18 deletions

View file

@ -87,8 +87,8 @@ func New(cfg *config.Config) (*App, error) {
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository) moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository)
paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository) paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository)
recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository) recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository, profileRepository)
recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService) recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService, profileRepository)
budgetService := service.NewBudgetService(budgetRepository) budgetService := service.NewBudgetService(budgetRepository)
reportService := service.NewReportService(expenseRepository) reportService := service.NewReportService(expenseRepository)

View file

@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE profiles ADD COLUMN timezone TEXT;
-- +goose Down
ALTER TABLE profiles DROP COLUMN IF EXISTS timezone;

View file

@ -15,15 +15,25 @@ import (
type settingsHandler struct { type settingsHandler struct {
authService *service.AuthService authService *service.AuthService
userService *service.UserService userService *service.UserService
profileService *service.ProfileService
} }
func NewSettingsHandler(authService *service.AuthService, userService *service.UserService) *settingsHandler { func NewSettingsHandler(authService *service.AuthService, userService *service.UserService, profileService *service.ProfileService) *settingsHandler {
return &settingsHandler{ return &settingsHandler{
authService: authService, authService: authService,
userService: userService, userService: userService,
profileService: profileService,
} }
} }
func (h *settingsHandler) currentTimezone(r *http.Request) string {
profile := ctxkeys.Profile(r.Context())
if profile != nil && profile.Timezone != nil {
return *profile.Timezone
}
return "UTC"
}
func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context()) user := ctxkeys.User(r.Context())
@ -35,7 +45,7 @@ func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
return return
} }
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "")) ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "", h.currentTimezone(r)))
} }
func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) { func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
@ -53,6 +63,8 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
return return
} }
currentTz := h.currentTimezone(r)
err = h.authService.SetPassword(user.ID, currentPassword, newPassword, confirmPassword) err = h.authService.SetPassword(user.ID, currentPassword, newPassword, confirmPassword)
if err != nil { if err != nil {
slog.Warn("set password failed", "error", err, "user_id", user.ID) slog.Warn("set password failed", "error", err, "user_id", user.ID)
@ -66,12 +78,12 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
msg = "Password must be at least 12 characters" msg = "Password must be at least 12 characters"
} }
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg)) ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg, currentTz))
return return
} }
// Password set successfully — render page with success toast // Password set successfully — render page with success toast
ui.Render(w, r, pages.AppSettings(true, "")) ui.Render(w, r, pages.AppSettings(true, "", currentTz))
ui.RenderToast(w, r, toast.Toast(toast.Props{ ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Password updated", Title: "Password updated",
Variant: toast.VariantSuccess, Variant: toast.VariantSuccess,
@ -80,3 +92,37 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
Duration: 5000, Duration: 5000,
})) }))
} }
func (h *settingsHandler) SetTimezone(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
tz := r.FormValue("timezone")
fullUser, err := h.userService.ByID(user.ID)
if err != nil {
slog.Error("failed to fetch user for set timezone", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = h.profileService.UpdateTimezone(user.ID, tz)
if err != nil {
slog.Warn("set timezone failed", "error", err, "user_id", user.ID)
msg := "Invalid timezone selected"
if !errors.Is(err, service.ErrInvalidTimezone) {
msg = "An error occurred. Please try again."
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg, h.currentTimezone(r)))
return
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "", tz))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Timezone updated",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}

View file

@ -6,6 +6,20 @@ type Profile struct {
ID string `db:"id"` ID string `db:"id"`
UserID string `db:"user_id"` UserID string `db:"user_id"`
Name string `db:"name"` Name string `db:"name"`
Timezone *string `db:"timezone"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
// Location returns the *time.Location for this profile's timezone.
// Returns UTC if timezone is nil or invalid.
func (p *Profile) Location() *time.Location {
if p.Timezone == nil || *p.Timezone == "" {
return time.UTC
}
loc, err := time.LoadLocation(*p.Timezone)
if err != nil {
return time.UTC
}
return loc
}

View file

@ -18,6 +18,7 @@ type ProfileRepository interface {
Create(profile *model.Profile) (string, error) Create(profile *model.Profile) (string, error)
ByUserID(userID string) (*model.Profile, error) ByUserID(userID string) (*model.Profile, error)
UpdateName(userID, name string) error UpdateName(userID, name string) error
UpdateTimezone(userID, timezone string) error
} }
type profileRepository struct { type profileRepository struct {
@ -61,6 +62,28 @@ func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) {
return &profile, nil return &profile, nil
} }
func (r *profileRepository) UpdateTimezone(userID, timezone string) error {
result, err := r.db.Exec(`
UPDATE profiles
SET timezone = $1, updated_at = $2
WHERE user_id = $3
`, timezone, time.Now(), userID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("no profile found for user_id: %s", userID)
}
return nil
}
func (r *profileRepository) UpdateName(userID, name string) error { func (r *profileRepository) UpdateName(userID, name string) error {
result, err := r.db.Exec(` result, err := r.db.Exec(`
UPDATE profiles UPDATE profiles

View file

@ -13,7 +13,7 @@ import (
func SetupRoutes(a *app.App) http.Handler { func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
home := handler.NewHomeHandler() home := handler.NewHomeHandler()
settings := handler.NewSettingsHandler(a.AuthService, a.UserService) settings := handler.NewSettingsHandler(a.AuthService, a.UserService, a.ProfileService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.RecurringDepositService, a.BudgetService, a.ReportService) space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.RecurringDepositService, a.BudgetService, a.ReportService)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -59,6 +59,7 @@ func SetupRoutes(a *app.App) http.Handler {
mux.Handle("POST /app/spaces", crudLimiter(middleware.RequireAuth(space.CreateSpace))) mux.Handle("POST /app/spaces", crudLimiter(middleware.RequireAuth(space.CreateSpace)))
mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage)) mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword))) mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
mux.HandleFunc("POST /app/settings/timezone", middleware.RequireAuth(settings.SetTimezone))
// Space routes — wrapping order: Auth(SpaceAccess(handler)) // Space routes — wrapping order: Auth(SpaceAccess(handler))
// Auth runs first (outer), then SpaceAccess (inner), then the handler. // Auth runs first (outer), then SpaceAccess (inner), then the handler.

View file

@ -1,10 +1,15 @@
package service package service
import ( import (
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
) )
var ErrInvalidTimezone = errors.New("invalid timezone")
type ProfileService struct { type ProfileService struct {
profileRepository repository.ProfileRepository profileRepository repository.ProfileRepository
} }
@ -18,3 +23,10 @@ func NewProfileService(profileRepository repository.ProfileRepository) *ProfileS
func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) { func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) {
return s.profileRepository.ByUserID(userID) return s.profileRepository.ByUserID(userID)
} }
func (s *ProfileService) UpdateTimezone(userID, timezone string) error {
if _, err := time.LoadLocation(timezone); err != nil {
return ErrInvalidTimezone
}
return s.profileRepository.UpdateTimezone(userID, timezone)
}

View file

@ -36,13 +36,15 @@ type RecurringDepositService struct {
recurringRepo repository.RecurringDepositRepository recurringRepo repository.RecurringDepositRepository
accountRepo repository.MoneyAccountRepository accountRepo repository.MoneyAccountRepository
expenseService *ExpenseService expenseService *ExpenseService
profileRepo repository.ProfileRepository
} }
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService) *RecurringDepositService { func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository) *RecurringDepositService {
return &RecurringDepositService{ return &RecurringDepositService{
recurringRepo: recurringRepo, recurringRepo: recurringRepo,
accountRepo: accountRepo, accountRepo: accountRepo,
expenseService: expenseService, expenseService: expenseService,
profileRepo: profileRepo,
} }
} }
@ -161,8 +163,10 @@ func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error {
return fmt.Errorf("failed to get due recurring deposits: %w", err) return fmt.Errorf("failed to get due recurring deposits: %w", err)
} }
tzCache := make(map[string]*time.Location)
for _, rd := range dues { for _, rd := range dues {
if err := s.processRecurrence(rd, now); err != nil { userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, userNow); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err) slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
} }
} }
@ -175,14 +179,30 @@ func (s *RecurringDepositService) ProcessDueRecurrencesForSpace(spaceID string,
return fmt.Errorf("failed to get due recurring deposits for space: %w", err) return fmt.Errorf("failed to get due recurring deposits for space: %w", err)
} }
tzCache := make(map[string]*time.Location)
for _, rd := range dues { for _, rd := range dues {
if err := s.processRecurrence(rd, now); err != nil { userNow := s.getUserNow(rd.CreatedBy, now, tzCache)
if err := s.processRecurrence(rd, userNow); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err) slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
} }
} }
return nil return nil
} }
func (s *RecurringDepositService) getUserNow(userID string, now time.Time, cache map[string]*time.Location) time.Time {
if loc, ok := cache[userID]; ok {
return now.In(loc)
}
loc := time.UTC
profile, err := s.profileRepo.ByUserID(userID)
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userID] = loc
return now.In(loc)
}
func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) { func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) {
totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID) totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID)
if err != nil { if err != nil {

View file

@ -38,12 +38,14 @@ type UpdateRecurringExpenseDTO struct {
type RecurringExpenseService struct { type RecurringExpenseService struct {
recurringRepo repository.RecurringExpenseRepository recurringRepo repository.RecurringExpenseRepository
expenseRepo repository.ExpenseRepository expenseRepo repository.ExpenseRepository
profileRepo repository.ProfileRepository
} }
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository) *RecurringExpenseService { func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository, profileRepo repository.ProfileRepository) *RecurringExpenseService {
return &RecurringExpenseService{ return &RecurringExpenseService{
recurringRepo: recurringRepo, recurringRepo: recurringRepo,
expenseRepo: expenseRepo, expenseRepo: expenseRepo,
profileRepo: profileRepo,
} }
} }
@ -176,8 +178,10 @@ func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
return fmt.Errorf("failed to get due recurrences: %w", err) return fmt.Errorf("failed to get due recurrences: %w", err)
} }
tzCache := make(map[string]*time.Location)
for _, re := range dues { for _, re := range dues {
if err := s.processRecurrence(re, now); err != nil { userNow := s.getUserNow(re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, userNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err) slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
} }
} }
@ -190,8 +194,10 @@ func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string,
return fmt.Errorf("failed to get due recurrences for space: %w", err) return fmt.Errorf("failed to get due recurrences for space: %w", err)
} }
tzCache := make(map[string]*time.Location)
for _, re := range dues { for _, re := range dues {
if err := s.processRecurrence(re, now); err != nil { userNow := s.getUserNow(re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, userNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err) slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
} }
} }
@ -247,6 +253,22 @@ func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense,
return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence) return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence)
} }
// getUserNow converts the server time to the user's local time based on their
// profile timezone setting. Falls back to UTC if no timezone is set.
func (s *RecurringExpenseService) getUserNow(userID string, now time.Time, cache map[string]*time.Location) time.Time {
if loc, ok := cache[userID]; ok {
return now.In(loc)
}
loc := time.UTC
profile, err := s.profileRepo.ByUserID(userID)
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userID] = loc
return now.In(loc)
}
func AdvanceDate(date time.Time, freq model.Frequency) time.Time { func AdvanceDate(date time.Time, freq model.Frequency) time.Time {
switch freq { switch freq {
case model.FrequencyDaily: case model.FrequencyDaily:

View file

@ -0,0 +1,59 @@
package timezone
type TimezoneOption struct {
Value string
Label string
}
func CommonTimezones() []TimezoneOption {
return []TimezoneOption{
{Value: "UTC", Label: "UTC"},
{Value: "America/New_York", Label: "Eastern Time (US & Canada)"},
{Value: "America/Chicago", Label: "Central Time (US & Canada)"},
{Value: "America/Denver", Label: "Mountain Time (US & Canada)"},
{Value: "America/Los_Angeles", Label: "Pacific Time (US & Canada)"},
{Value: "America/Anchorage", Label: "Alaska"},
{Value: "Pacific/Honolulu", Label: "Hawaii"},
{Value: "America/Phoenix", Label: "Arizona"},
{Value: "America/Toronto", Label: "Toronto"},
{Value: "America/Vancouver", Label: "Vancouver"},
{Value: "America/Edmonton", Label: "Edmonton"},
{Value: "America/Winnipeg", Label: "Winnipeg"},
{Value: "America/Halifax", Label: "Atlantic Time (Canada)"},
{Value: "America/St_Johns", Label: "Newfoundland"},
{Value: "America/Mexico_City", Label: "Mexico City"},
{Value: "America/Bogota", Label: "Bogota"},
{Value: "America/Lima", Label: "Lima"},
{Value: "America/Santiago", Label: "Santiago"},
{Value: "America/Argentina/Buenos_Aires", Label: "Buenos Aires"},
{Value: "America/Sao_Paulo", Label: "Sao Paulo"},
{Value: "Europe/London", Label: "London"},
{Value: "Europe/Paris", Label: "Paris"},
{Value: "Europe/Berlin", Label: "Berlin"},
{Value: "Europe/Madrid", Label: "Madrid"},
{Value: "Europe/Rome", Label: "Rome"},
{Value: "Europe/Amsterdam", Label: "Amsterdam"},
{Value: "Europe/Zurich", Label: "Zurich"},
{Value: "Europe/Stockholm", Label: "Stockholm"},
{Value: "Europe/Helsinki", Label: "Helsinki"},
{Value: "Europe/Warsaw", Label: "Warsaw"},
{Value: "Europe/Istanbul", Label: "Istanbul"},
{Value: "Europe/Moscow", Label: "Moscow"},
{Value: "Asia/Dubai", Label: "Dubai"},
{Value: "Asia/Kolkata", Label: "India (Kolkata)"},
{Value: "Asia/Bangkok", Label: "Bangkok"},
{Value: "Asia/Singapore", Label: "Singapore"},
{Value: "Asia/Hong_Kong", Label: "Hong Kong"},
{Value: "Asia/Shanghai", Label: "Shanghai"},
{Value: "Asia/Tokyo", Label: "Tokyo"},
{Value: "Asia/Seoul", Label: "Seoul"},
{Value: "Australia/Sydney", Label: "Sydney"},
{Value: "Australia/Melbourne", Label: "Melbourne"},
{Value: "Australia/Perth", Label: "Perth"},
{Value: "Australia/Brisbane", Label: "Brisbane"},
{Value: "Pacific/Auckland", Label: "Auckland"},
{Value: "Africa/Cairo", Label: "Cairo"},
{Value: "Africa/Johannesburg", Label: "Johannesburg"},
{Value: "Africa/Lagos", Label: "Lagos"},
}
}

View file

@ -1,6 +1,7 @@
package pages package pages
import ( import (
"git.juancwu.dev/juancwu/budgit/internal/timezone"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
@ -8,15 +9,55 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input" "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label" "git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card" "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
) )
templ AppSettings(hasPassword bool, errorMsg string) { templ AppSettings(hasPassword bool, errorMsg string, currentTimezone string) {
@layouts.App("Settings") { @layouts.App("Settings") {
<div class="container max-w-2xl px-6 py-8"> <div class="container max-w-2xl px-6 py-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold">Settings</h1> <h1 class="text-3xl font-bold">Settings</h1>
<p class="text-muted-foreground mt-2">Manage your account settings</p> <p class="text-muted-foreground mt-2">Manage your account settings</p>
</div> </div>
@card.Card() {
@card.Header() {
@card.Title() {
Timezone
}
@card.Description() {
Set your timezone for recurring expenses and reports
}
}
@card.Content() {
<form action="/app/settings/timezone" method="POST" class="space-y-4">
@csrf.Token()
@form.Item() {
@label.Label(label.Props{
For: "timezone",
Class: "block mb-2",
}) {
Timezone
}
@selectbox.SelectBox(selectbox.Props{ID: "timezone-select"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
}
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
for _, tz := range timezone.CommonTimezones() {
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: tz.Value == currentTimezone}) {
{ tz.Label }
}
}
}
}
}
@button.Submit() {
Update Timezone
}
</form>
}
}
<div class="mt-6"></div>
@card.Card() { @card.Card() {
@card.Header() { @card.Header() {
@card.Title() { @card.Title() {