feat: set timezone user level
This commit is contained in:
parent
ad0989510a
commit
945069052f
11 changed files with 261 additions and 18 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE profiles ADD COLUMN timezone TEXT;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE profiles DROP COLUMN IF EXISTS timezone;
|
||||||
|
|
@ -13,17 +13,27 @@ 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
59
internal/timezone/timezones.go
Normal file
59
internal/timezone/timezones.go
Normal 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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue