diff --git a/TIMEZONE_PLAN.md b/TIMEZONE_PLAN.md new file mode 100644 index 0000000..fcf85b7 --- /dev/null +++ b/TIMEZONE_PLAN.md @@ -0,0 +1,363 @@ +# Timezone Implementation Plan + +## Overview + +Add per-user timezone support with system-wide fallback (set via docker-compose), defaulting to UTC. The database already stores all timestamps as UTC via `TIMESTAMPTZ` columns, so this is primarily a display-layer feature. + +**Display format:** MM-DD-YYYY HH:MM (US convention, no timezone abbreviation shown) + +**Timezone selection:** Manual dropdown only (no browser auto-detect) + +--- + +## Phase 1: Database Schema + +**File:** `database/schema/schema.sql` + +1. Add `timezone` column to `users` table: + +```sql +ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) DEFAULT 'UTC'; +``` + +2. Add system-wide default timezone to `system_settings`: + +```sql +INSERT INTO system_settings (setting_key, setting_value, description) VALUES +('default_timezone', 'UTC', 'System default timezone') +ON CONFLICT (setting_key) DO NOTHING; +``` + +3. Add index: + +```sql +CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone); +``` + +4. Regenerate sqlc code: + +```bash +cd internal/database && sqlc generate +``` + +--- + +## Phase 2: Database Queries + +**File:** `internal/database/queries/queries.sql` + +Add new queries: + +```sql +-- name: UpdateUserTimezone :exec +UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1; + +-- name: GetSystemTimezone :one +SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'; + +-- name: UpdateSystemTimezone :exec +UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = 'default_timezone'; +``` + +Regenerate after adding queries: + +```bash +cd internal/database && sqlc generate +``` + +--- + +## Phase 3: Template Utilities + +**File:** `templates/utils.go` + +Add timezone-aware time formatting helpers: + +```go +package templates + +import ( + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM +func FormatInTimezone(t time.Time, timezone string) string { + if t.IsZero() { + return "" + } + + loc, err := time.LoadLocation(timezone) + if err != nil { + loc = time.UTC + } + + return t.In(loc).Format("01-02-2006 15:04") +} + +// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone +func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string { + if !t.Valid { + return "" + } + return FormatInTimezone(t.Time, timezone) +} +``` + +--- + +## Phase 4: User Context Update + +**File:** `templates/types.go` + +Add `Timezone` field to the `User` struct: + +```go +type User struct { + ID string + Email string + Username string + Role string + Theme string + FirstName string + LastName string + CreatedAt time.Time + Token string + Timezone string +} +``` + +**File:** `internal/router/helpers.go` + +Update `getTemplateUserWithTheme()` to include timezone: + +```go +userTimezone := "UTC" +if userDB.Timezone.Valid { + userTimezone = userDB.Timezone.String +} + +return templates.User{ + // ... existing fields ... + Timezone: userTimezone, +} +``` + +--- + +## Phase 5: Handlers + +**File:** `internal/handlers/auth.go` + +Update `UpdateProfileRequest` struct: + +```go +type UpdateProfileRequest struct { + Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` + Email string `json:"email,omitempty" validate:"omitempty,email"` + FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` + LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` + Theme string `json:"theme,omitempty" validate:"omitempty"` + Timezone string `json:"timezone,omitempty" validate:"omitempty"` +} +``` + +Add timezone update logic in `UpdateProfile()`: + +```go +if req.Timezone != "" { + if _, err := time.LoadLocation(req.Timezone); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid timezone", + }) + } + err := h.db.UpdateUserTimezone(ctx, database.UpdateUserTimezoneParams{ + ID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Timezone: pgtype.Text{String: req.Timezone, Valid: true}, + }) + if err != nil { + return err + } +} +``` + +**File:** `internal/handlers/system_settings.go` + +Add timezone settings handler: + +```go +type UpdateTimezoneSettingsRequest struct { + DefaultTimezone string `json:"default_timezone" validate:"required"` +} + +func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error { + var req UpdateTimezoneSettingsRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + if _, err := time.LoadLocation(req.DefaultTimezone); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"}) + } + err := h.db.UpdateSystemTimezone(c.Request().Context(), database.UpdateSystemTimezoneParams{ + SettingKey: "default_timezone", + SettingValue: req.DefaultTimezone, + }) + if err != nil { + return err + } + return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"}) +} +``` + +--- + +## Phase 6: User Profile UI + +**File:** `templates/profile_form.templ` + +Add timezone dropdown after the theme field: + +```templ +