diff --git a/internal/app/app.go b/internal/app/app.go index 174fd28..307f11f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -87,8 +87,8 @@ func New(cfg *config.Config) (*App, error) { inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository) paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository) - recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository) - recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService) + recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository, profileRepository) + recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService, profileRepository) budgetService := service.NewBudgetService(budgetRepository) reportService := service.NewReportService(expenseRepository) diff --git a/internal/db/migrations/00015_add_timezone_to_profiles.sql b/internal/db/migrations/00015_add_timezone_to_profiles.sql new file mode 100644 index 0000000..e4f6e8c --- /dev/null +++ b/internal/db/migrations/00015_add_timezone_to_profiles.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE profiles ADD COLUMN timezone TEXT; + +-- +goose Down +ALTER TABLE profiles DROP COLUMN IF EXISTS timezone; diff --git a/internal/handler/settings.go b/internal/handler/settings.go index bf80a63..e96d54a 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -13,17 +13,27 @@ import ( ) type settingsHandler struct { - authService *service.AuthService - userService *service.UserService + authService *service.AuthService + 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{ - authService: authService, - userService: userService, + authService: authService, + 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) { user := ctxkeys.User(r.Context()) @@ -35,7 +45,7 @@ func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { 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) { @@ -53,6 +63,8 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) { return } + currentTz := h.currentTimezone(r) + err = h.authService.SetPassword(user.ID, currentPassword, newPassword, confirmPassword) if err != nil { 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" } - ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg)) + ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg, currentTz)) return } // 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{ Title: "Password updated", Variant: toast.VariantSuccess, @@ -80,3 +92,37 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) { 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, + })) +} diff --git a/internal/model/profile.go b/internal/model/profile.go index 32ef07a..1df43ce 100644 --- a/internal/model/profile.go +++ b/internal/model/profile.go @@ -6,6 +6,20 @@ type Profile struct { ID string `db:"id"` UserID string `db:"user_id"` Name string `db:"name"` + Timezone *string `db:"timezone"` CreatedAt time.Time `db:"created_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 +} diff --git a/internal/repository/profile.go b/internal/repository/profile.go index c37b191..c25c30a 100644 --- a/internal/repository/profile.go +++ b/internal/repository/profile.go @@ -18,6 +18,7 @@ type ProfileRepository interface { Create(profile *model.Profile) (string, error) ByUserID(userID string) (*model.Profile, error) UpdateName(userID, name string) error + UpdateTimezone(userID, timezone string) error } type profileRepository struct { @@ -61,6 +62,28 @@ func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) { 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 { result, err := r.db.Exec(` UPDATE profiles diff --git a/internal/routes/routes.go b/internal/routes/routes.go index f63a599..1c51117 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -13,7 +13,7 @@ import ( func SetupRoutes(a *app.App) http.Handler { auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) 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) 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.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage)) 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)) // Auth runs first (outer), then SpaceAccess (inner), then the handler. diff --git a/internal/service/profile.go b/internal/service/profile.go index 95cda4d..54d99f3 100644 --- a/internal/service/profile.go +++ b/internal/service/profile.go @@ -1,10 +1,15 @@ package service import ( + "errors" + "time" + "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" ) +var ErrInvalidTimezone = errors.New("invalid timezone") + type ProfileService struct { profileRepository repository.ProfileRepository } @@ -18,3 +23,10 @@ func NewProfileService(profileRepository repository.ProfileRepository) *ProfileS func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) { 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) +} diff --git a/internal/service/recurring_deposit.go b/internal/service/recurring_deposit.go index efb0aa2..bace5df 100644 --- a/internal/service/recurring_deposit.go +++ b/internal/service/recurring_deposit.go @@ -36,13 +36,15 @@ type RecurringDepositService struct { recurringRepo repository.RecurringDepositRepository accountRepo repository.MoneyAccountRepository 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{ recurringRepo: recurringRepo, accountRepo: accountRepo, 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) } + tzCache := make(map[string]*time.Location) 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) } } @@ -175,14 +179,30 @@ func (s *RecurringDepositService) ProcessDueRecurrencesForSpace(spaceID string, return fmt.Errorf("failed to get due recurring deposits for space: %w", err) } + tzCache := make(map[string]*time.Location) 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) } } 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) { totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID) if err != nil { diff --git a/internal/service/recurring_expense.go b/internal/service/recurring_expense.go index 0a4a581..d72ed8d 100644 --- a/internal/service/recurring_expense.go +++ b/internal/service/recurring_expense.go @@ -38,12 +38,14 @@ type UpdateRecurringExpenseDTO struct { type RecurringExpenseService struct { recurringRepo repository.RecurringExpenseRepository 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{ recurringRepo: recurringRepo, 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) } + tzCache := make(map[string]*time.Location) 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) } } @@ -190,8 +194,10 @@ func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string, return fmt.Errorf("failed to get due recurrences for space: %w", err) } + tzCache := make(map[string]*time.Location) 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) } } @@ -247,6 +253,22 @@ func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense, 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 { switch freq { case model.FrequencyDaily: diff --git a/internal/timezone/timezones.go b/internal/timezone/timezones.go new file mode 100644 index 0000000..ed21ec0 --- /dev/null +++ b/internal/timezone/timezones.go @@ -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"}, + } +} diff --git a/internal/ui/pages/app_settings.templ b/internal/ui/pages/app_settings.templ index 01fec07..ec1163d 100644 --- a/internal/ui/pages/app_settings.templ +++ b/internal/ui/pages/app_settings.templ @@ -1,6 +1,7 @@ package pages import ( + "git.juancwu.dev/juancwu/budgit/internal/timezone" "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/csrf" @@ -8,15 +9,55 @@ import ( "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/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") {

Settings

Manage your account settings

+ @card.Card() { + @card.Header() { + @card.Title() { + Timezone + } + @card.Description() { + Set your timezone for recurring expenses and reports + } + } + @card.Content() { +
+ @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 + } +
+ } + } +
@card.Card() { @card.Header() { @card.Title() {