feat: set timezone space level
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m25s

This commit is contained in:
juancwu 2026-03-03 14:48:11 +00:00
commit 08f6b034f5
12 changed files with 196 additions and 23 deletions

View file

@ -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)

View file

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

View file

@ -22,7 +22,8 @@ func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.Aut
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, profileRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
userSvc := service.NewUserService(userRepo)
return NewSettingsHandler(authSvc, userSvc), authSvc
profileSvc := service.NewProfileService(profileRepo)
return NewSettingsHandler(authSvc, userSvc, profileSvc), authSvc
}
func TestSettingsHandler_SettingsPage(t *testing.T) {

View file

@ -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")

View file

@ -19,6 +19,7 @@ func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
inviteRepo := repository.NewInvitationRepository(dbi.DB)
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
@ -36,8 +37,8 @@ func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler {
service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc),
service.NewMoneyAccountService(accountRepo),
service.NewPaymentMethodService(methodRepo),
service.NewRecurringExpenseService(recurringRepo, expenseRepo),
service.NewRecurringDepositService(recurringDepositRepo, accountRepo, expenseSvc),
service.NewRecurringExpenseService(recurringRepo, expenseRepo, profileRepo, spaceRepo),
service.NewRecurringDepositService(recurringDepositRepo, accountRepo, expenseSvc, profileRepo, spaceRepo),
service.NewBudgetService(budgetRepo),
service.NewReportService(expenseRepo),
)

View file

@ -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"`

View file

@ -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
}

View file

@ -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))

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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 {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/timezone" }
hx-swap="none"
class="space-y-4"
>
@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
}
</form>
} else {
if space.Timezone != nil && *space.Timezone != "" {
<p class="text-sm">{ *space.Timezone }</p>
} else {
<p class="text-sm text-muted-foreground">Not set (uses your timezone)</p>
}
}
}
}
// Members Section
@card.Card() {
@card.Header() {