From 1863fb3380e5373a37acdc99a132c6e41825223f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 26 Apr 2026 21:21:49 -0400 Subject: [PATCH] docs: add TIMEZONE_PLAN.md with full timezone implementation plan Documents the approach for adding per-user timezone support with system-wide fallback. The database already stores all timestamps as UTC via TIMESTAMPTZ columns, so the work is primarily in the display layer: user preference storage, timezone-aware template helpers, and UI controls for selecting a timezone. Covers 9 phases: schema changes, sqlc queries, template utilities, user context updates, handler changes, profile/admin UI, template time display conversion, docker config, and testing/deployment steps. --- TIMEZONE_PLAN.md | 363 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 TIMEZONE_PLAN.md 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 +
+ + +
+``` + +Include timezone in the HTMX form submission payload. + +--- + +## Phase 7: Admin Settings UI + +**File:** `templates/admin_settings.templ` + +Add system default timezone setting: + +```templ +
+

System Defaults

+ + +
+``` + +--- + +## Phase 8: Template Time Display Updates + +### Files to update + +| Template | Line(s) | Field(s) | +|----------|---------|----------| +| `templates/book_detail.templ` | ~253, ~282 | `LastReadAt`, `DatePublished` | +| `templates/book_detail_modals.templ` | ~68, ~113 | `Timestamp`, `LastReadAt` | +| `templates/devices.templ` | ~83, ~91, ~174 | `LastSync`, `LastSeen`, `ExpiresAt` | +| `templates/conflicts.templ` | ~114 | `CreatedAt` | +| `templates/admin_users.templ` | ~89 | `CreatedAt` | +| `templates/queue.templ` | ~138 | `CreatedAt` | + +### Change pattern + +```templ + +{ book.ReadingProgress.LastReadAt.Time.Format("2006-01-02 15:04") } + + +{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) } +``` + +For `time.Time` fields: + +```templ + +{ device.LastSync.Format("2006-01-02 15:04") } + + +{ templates.FormatInTimezone(device.LastSync, user.Timezone) } +``` + +--- + +## Phase 9: Docker Configuration + +**File:** `docker-compose.yml` + +```yaml +services: + server: + environment: + - TZ=UTC +``` + +**File:** `.env.example` + +``` +# System default timezone (fallback if not set in DB) +TZ=UTC +``` + +--- + +## Files Modified Summary + +| File | Change | +|------|--------| +| `database/schema/schema.sql` | Add timezone column to users, system_setting row | +| `internal/database/queries/queries.sql` | Add UpdateUserTimezone, GetSystemTimezone, UpdateSystemTimezone | +| `templates/utils.go` | Add FormatInTimezone, FormatTimestamptzInTimezone | +| `templates/types.go` | Add Timezone field to User struct | +| `internal/router/helpers.go` | Pass timezone to template User | +| `internal/handlers/auth.go` | Handle timezone updates in UpdateProfile | +| `internal/handlers/system_settings.go` | Add timezone settings handler | +| `templates/profile_form.templ` | Add timezone dropdown | +| `templates/admin_settings.templ` | Add default timezone setting | +| `templates/book_detail.templ` | Update time displays | +| `templates/book_detail_modals.templ` | Update time displays | +| `templates/devices.templ` | Update time displays | +| `templates/conflicts.templ` | Update time displays | +| `templates/admin_users.templ` | Update time displays | +| `templates/queue.templ` | Update time displays | +| `docker-compose.yml` | Add TZ env var | +| `.env.example` | Add TZ example | + +--- + +## Testing Checklist + +- [ ] Create user, set timezone to Eastern, verify times display in MM-DD-YYYY HH:MM format +- [ ] Create user, set timezone to Pacific, verify different offset +- [ ] Test system default timezone fallback for users with no timezone set +- [ ] Verify invalid timezone values are rejected by the API +- [ ] Verify existing users (no timezone set) fall back to system default +- [ ] Verify all templates show consistent MM-DD-YYYY HH:MM format +- [ ] Run `make test-integration` to verify no regressions + +--- + +## Deployment Steps + +1. Update `database/schema/schema.sql` with new column and settings +2. Regenerate sqlc: `cd internal/database && sqlc generate` +3. Apply schema changes (restart database container with `make rebuild-force-db`) +4. Deploy backend code changes +5. Verify with existing data