feat: set timezone space level
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m25s
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m25s
This commit is contained in:
parent
945069052f
commit
08f6b034f5
12 changed files with 196 additions and 23 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
5
internal/db/migrations/00016_add_timezone_to_spaces.sql
Normal file
5
internal/db/migrations/00016_add_timezone_to_spaces.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE spaces ADD COLUMN timezone TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE spaces DROP COLUMN IF EXISTS timezone;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue