# 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 directly to the `users` table definition (line ~36): ```sql CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, username VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, first_name VARCHAR(255), last_name VARCHAR(255), role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')), theme VARCHAR(50) DEFAULT 'tokyo-night', max_devices INTEGER DEFAULT 10, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), timezone VARCHAR(50) DEFAULT 'UTC' ); ``` > Note: The `timezone` column is already present at line 36 in the current schema. No change needed for this step. 1. Add `default_timezone` to the `system_settings` INSERT block (line ~49-52): ```sql INSERT INTO system_settings (setting_key, setting_value, description) VALUES ('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'), ('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'), ('default_timezone', 'UTC', 'System default timezone') ON CONFLICT (setting_key) DO NOTHING; ``` 1. Add index in the indexes section (after line ~460, with other user indexes): ```sql CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone); ``` 1. 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'; ``` > Note: `UpdateSystemTimezone` is omitted because the existing `UpdateSystemSetting` query handles it by passing `'default_timezone'` as the key parameter. 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 03:04 PM") } // 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.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{ 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("01-02-2006 03:04 PM") } { templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) } ``` For `time.Time` fields: ```templ { device.LastSync.Format("01-02-2006 03:04 PM") } { 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 (reuses existing UpdateSystemSetting) | | `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