From 64891638d1dd4c3706beeb0bafea121baa336abe Mon Sep 17 00:00:00 2001 From: juancwu Date: Tue, 3 Mar 2026 14:48:11 +0000 Subject: [PATCH] feat: set timezone space level --- internal/app/app.go | 4 +- .../00016_add_timezone_to_spaces.sql | 5 ++ internal/handler/space.go | 40 +++++++++++++ internal/model/space.go | 14 +++++ internal/repository/space.go | 7 +++ internal/routes/routes.go | 4 ++ internal/service/recurring_deposit.go | 34 ++++++++--- internal/service/recurring_expense.go | 36 +++++++---- internal/service/space.go | 8 +++ internal/ui/pages/app_space_settings.templ | 59 +++++++++++++++++++ 10 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 internal/db/migrations/00016_add_timezone_to_spaces.sql diff --git a/internal/app/app.go b/internal/app/app.go index 307f11f..ed6f9f5 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, profileRepository) - recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService, profileRepository) + recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository, profileRepository, spaceRepository) + recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService, profileRepository, spaceRepository) budgetService := service.NewBudgetService(budgetRepository) reportService := service.NewReportService(expenseRepository) diff --git a/internal/db/migrations/00016_add_timezone_to_spaces.sql b/internal/db/migrations/00016_add_timezone_to_spaces.sql new file mode 100644 index 0000000..584bf53 --- /dev/null +++ b/internal/db/migrations/00016_add_timezone_to_spaces.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE spaces ADD COLUMN timezone TEXT; + +-- +goose Down +ALTER TABLE spaces DROP COLUMN IF EXISTS timezone; diff --git a/internal/handler/space.go b/internal/handler/space.go index a5f0a50..fe68265 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -1177,6 +1177,46 @@ func (h *SpaceHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func (h *SpaceHandler) UpdateSpaceTimezone(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + tz := r.FormValue("timezone") + if tz == "" { + ui.RenderError(w, r, "Timezone is required", http.StatusUnprocessableEntity) + return + } + + if err := h.spaceService.UpdateSpaceTimezone(spaceID, tz); err != nil { + if err == service.ErrInvalidTimezone { + ui.RenderError(w, r, "Invalid timezone", http.StatusUnprocessableEntity) + return + } + slog.Error("failed to update space timezone", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusOK) +} + func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") userID := r.PathValue("userID") diff --git a/internal/model/space.go b/internal/model/space.go index f18856c..5cebf4e 100644 --- a/internal/model/space.go +++ b/internal/model/space.go @@ -13,10 +13,24 @@ type Space struct { ID string `db:"id"` Name string `db:"name"` OwnerID string `db:"owner_id"` + Timezone *string `db:"timezone"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } +// Location returns the *time.Location for this space's timezone. +// Returns nil if timezone is not set, so callers can distinguish "not set" from "UTC". +func (s *Space) Location() *time.Location { + if s.Timezone == nil || *s.Timezone == "" { + return nil + } + loc, err := time.LoadLocation(*s.Timezone) + if err != nil { + return nil + } + return loc +} + type SpaceMember struct { SpaceID string `db:"space_id"` UserID string `db:"user_id"` diff --git a/internal/repository/space.go b/internal/repository/space.go index a01f38c..3c06671 100644 --- a/internal/repository/space.go +++ b/internal/repository/space.go @@ -22,6 +22,7 @@ type SpaceRepository interface { IsMember(spaceID, userID string) (bool, error) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) UpdateName(spaceID, name string) error + UpdateTimezone(spaceID, timezone string) error } type spaceRepository struct { @@ -128,3 +129,9 @@ func (r *spaceRepository) UpdateName(spaceID, name string) error { _, err := r.db.Exec(query, name, time.Now(), spaceID) return err } + +func (r *spaceRepository) UpdateTimezone(spaceID, timezone string) error { + query := `UPDATE spaces SET timezone = $1, updated_at = $2 WHERE id = $3;` + _, err := r.db.Exec(query, timezone, time.Now(), spaceID) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 1c51117..be7f4d9 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -273,6 +273,10 @@ func SetupRoutes(a *app.App) http.Handler { updateSpaceNameWithAuth := middleware.RequireAuth(updateSpaceNameHandler) mux.Handle("PATCH /app/spaces/{spaceID}/settings/name", crudLimiter(updateSpaceNameWithAuth)) + updateSpaceTimezoneHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateSpaceTimezone) + updateSpaceTimezoneWithAuth := middleware.RequireAuth(updateSpaceTimezoneHandler) + mux.Handle("PATCH /app/spaces/{spaceID}/settings/timezone", crudLimiter(updateSpaceTimezoneWithAuth)) + removeMemberHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.RemoveMember) removeMemberWithAuth := middleware.RequireAuth(removeMemberHandler) mux.Handle("DELETE /app/spaces/{spaceID}/members/{userID}", crudLimiter(removeMemberWithAuth)) diff --git a/internal/service/recurring_deposit.go b/internal/service/recurring_deposit.go index bace5df..50605d5 100644 --- a/internal/service/recurring_deposit.go +++ b/internal/service/recurring_deposit.go @@ -37,14 +37,16 @@ type RecurringDepositService struct { accountRepo repository.MoneyAccountRepository expenseService *ExpenseService profileRepo repository.ProfileRepository + spaceRepo repository.SpaceRepository } -func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository) *RecurringDepositService { +func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository, spaceRepo repository.SpaceRepository) *RecurringDepositService { return &RecurringDepositService{ recurringRepo: recurringRepo, accountRepo: accountRepo, expenseService: expenseService, profileRepo: profileRepo, + spaceRepo: spaceRepo, } } @@ -165,8 +167,8 @@ func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error { tzCache := make(map[string]*time.Location) for _, rd := range dues { - userNow := s.getUserNow(rd.CreatedBy, now, tzCache) - if err := s.processRecurrence(rd, userNow); err != nil { + localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache) + if err := s.processRecurrence(rd, localNow); err != nil { slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err) } } @@ -181,16 +183,32 @@ func (s *RecurringDepositService) ProcessDueRecurrencesForSpace(spaceID string, tzCache := make(map[string]*time.Location) for _, rd := range dues { - userNow := s.getUserNow(rd.CreatedBy, now, tzCache) - if err := s.processRecurrence(rd, userNow); err != nil { + localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache) + if err := s.processRecurrence(rd, localNow); 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 { +// getLocalNow resolves the effective timezone for a recurring deposit. +// Resolution order: space timezone → user profile timezone → UTC. +func (s *RecurringDepositService) getLocalNow(spaceID, userID string, now time.Time, cache map[string]*time.Location) time.Time { + spaceKey := "space:" + spaceID + if loc, ok := cache[spaceKey]; ok { + return now.In(loc) + } + + space, err := s.spaceRepo.ByID(spaceID) + if err == nil && space != nil { + if loc := space.Location(); loc != nil { + cache[spaceKey] = loc + return now.In(loc) + } + } + + userKey := "user:" + userID + if loc, ok := cache[userKey]; ok { return now.In(loc) } @@ -199,7 +217,7 @@ func (s *RecurringDepositService) getUserNow(userID string, now time.Time, cache if err == nil && profile != nil { loc = profile.Location() } - cache[userID] = loc + cache[userKey] = loc return now.In(loc) } diff --git a/internal/service/recurring_expense.go b/internal/service/recurring_expense.go index d72ed8d..94ae983 100644 --- a/internal/service/recurring_expense.go +++ b/internal/service/recurring_expense.go @@ -39,13 +39,15 @@ type RecurringExpenseService struct { recurringRepo repository.RecurringExpenseRepository expenseRepo repository.ExpenseRepository profileRepo repository.ProfileRepository + spaceRepo repository.SpaceRepository } -func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository, profileRepo repository.ProfileRepository) *RecurringExpenseService { +func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository, profileRepo repository.ProfileRepository, spaceRepo repository.SpaceRepository) *RecurringExpenseService { return &RecurringExpenseService{ recurringRepo: recurringRepo, expenseRepo: expenseRepo, profileRepo: profileRepo, + spaceRepo: spaceRepo, } } @@ -180,8 +182,8 @@ func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error { tzCache := make(map[string]*time.Location) for _, re := range dues { - userNow := s.getUserNow(re.CreatedBy, now, tzCache) - if err := s.processRecurrence(re, userNow); err != nil { + localNow := s.getLocalNow(re.SpaceID, re.CreatedBy, now, tzCache) + if err := s.processRecurrence(re, localNow); err != nil { slog.Error("failed to process recurring expense", "id", re.ID, "error", err) } } @@ -196,8 +198,8 @@ func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string, tzCache := make(map[string]*time.Location) for _, re := range dues { - userNow := s.getUserNow(re.CreatedBy, now, tzCache) - if err := s.processRecurrence(re, userNow); err != nil { + localNow := s.getLocalNow(re.SpaceID, re.CreatedBy, now, tzCache) + if err := s.processRecurrence(re, localNow); err != nil { slog.Error("failed to process recurring expense", "id", re.ID, "error", err) } } @@ -253,10 +255,24 @@ 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 { +// getLocalNow resolves the effective timezone for a recurring expense. +// Resolution order: space timezone → user profile timezone → UTC. +func (s *RecurringExpenseService) getLocalNow(spaceID, userID string, now time.Time, cache map[string]*time.Location) time.Time { + spaceKey := "space:" + spaceID + if loc, ok := cache[spaceKey]; ok { + return now.In(loc) + } + + space, err := s.spaceRepo.ByID(spaceID) + if err == nil && space != nil { + if loc := space.Location(); loc != nil { + cache[spaceKey] = loc + return now.In(loc) + } + } + + userKey := "user:" + userID + if loc, ok := cache[userKey]; ok { return now.In(loc) } @@ -265,7 +281,7 @@ func (s *RecurringExpenseService) getUserNow(userID string, now time.Time, cache if err == nil && profile != nil { loc = profile.Location() } - cache[userID] = loc + cache[userKey] = loc return now.In(loc) } diff --git a/internal/service/space.go b/internal/service/space.go index d3990b2..5ed2ada 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -110,3 +110,11 @@ func (s *SpaceService) UpdateSpaceName(spaceID, name string) error { } return s.spaceRepo.UpdateName(spaceID, name) } + +// UpdateSpaceTimezone updates the timezone of a space. +func (s *SpaceService) UpdateSpaceTimezone(spaceID, timezone string) error { + if _, err := time.LoadLocation(timezone); err != nil { + return ErrInvalidTimezone + } + return s.spaceRepo.UpdateTimezone(spaceID, timezone) +} diff --git a/internal/ui/pages/app_space_settings.templ b/internal/ui/pages/app_space_settings.templ index f26fd85..b360098 100644 --- a/internal/ui/pages/app_space_settings.templ +++ b/internal/ui/pages/app_space_settings.templ @@ -3,13 +3,17 @@ package pages import ( "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/timezone" "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" "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/selectbox" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" ) @@ -55,6 +59,61 @@ templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProf } } } + // Timezone Section + @card.Card() { + @card.Header() { + @card.Title() { + Timezone + } + @card.Description() { + if isOwner { + Set a timezone for this space. Recurring expenses and reports will use this timezone. + } else { + The timezone used for recurring expenses and reports in this space. + } + } + } + @card.Content() { + if isOwner { +
+ @csrf.Token() + @form.Item() { + @label.Label(label.Props{ + For: "timezone", + Class: "block mb-2", + }) { + Timezone + } + @selectbox.SelectBox(selectbox.Props{ID: "space-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: space.Timezone != nil && tz.Value == *space.Timezone}) { + { tz.Label } + } + } + } + } + } + @button.Submit() { + Save Timezone + } +
+ } else { + if space.Timezone != nil && *space.Timezone != "" { +

{ *space.Timezone }

+ } else { +

Not set (uses your timezone)

+ } + } + } + } // Members Section @card.Card() { @card.Header() {