- Remove UpdateSystemTimezone query from plan; reuse existing UpdateSystemSetting with 'default_timezone' as the key parameter - Update handler code example to reference UpdateSystemSetting - Update FormatInTimezone format string to 12-hour (03:04 PM) - Update queries file description in summary table
13 KiB
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
- Add
timezonecolumn directly to theuserstable definition (line ~36):
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
timezonecolumn is already present at line 36 in the current schema. No change needed for this step.
- Add
default_timezoneto thesystem_settingsINSERT block (line ~49-52):
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;
- Add index in the indexes section (after line ~460, with other user indexes):
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
- Regenerate sqlc code:
cd internal/database && sqlc generate
Phase 2: Database Queries
File: internal/database/queries/queries.sql
Add new queries:
-- 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:
UpdateSystemTimezoneis omitted because the existingUpdateSystemSettingquery handles it by passing'default_timezone'as the key parameter.
Regenerate after adding queries:
cd internal/database && sqlc generate
Phase 3: Template Utilities
File: templates/utils.go
Add timezone-aware time formatting helpers:
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:
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:
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:
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():
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:
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:
<div class="form-group">
<label for="timezone">Timezone</label>
<select name="timezone" id="timezone" class="form-select">
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (Coordinated Universal Time)</option>
<option value="America/New_York" selected?={ user.Timezone == "America/New_York" }>Eastern Time</option>
<option value="America/Chicago" selected?={ user.Timezone == "America/Chicago" }>Central Time</option>
<option value="America/Denver" selected?={ user.Timezone == "America/Denver" }>Mountain Time</option>
<option value="America/Los_Angeles" selected?={ user.Timezone == "America/Los_Angeles" }>Pacific Time</option>
<option value="America/Phoenix" selected?={ user.Timezone == "America/Phoenix" }>Mountain Time (no DST)</option>
<option value="America/Anchorage" selected?={ user.Timezone == "America/Anchorage" }>Alaska Time</option>
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii Time</option>
</select>
</div>
Include timezone in the HTMX form submission payload.
Phase 7: Admin Settings UI
File: templates/admin_settings.templ
Add system default timezone setting:
<div class="setting-group">
<h3>System Defaults</h3>
<label for="default_timezone">Default Timezone</label>
<select name="default_timezone" id="default_timezone">
<option value="UTC">UTC (Coordinated Universal Time)</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="America/Phoenix">Mountain Time (no DST)</option>
<option value="America/Anchorage">Alaska Time</option>
<option value="Pacific/Honolulu">Hawaii Time</option>
</select>
</div>
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
<!-- Before -->
{ book.ReadingProgress.LastReadAt.Time.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
For time.Time fields:
<!-- Before -->
{ device.LastSync.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatInTimezone(device.LastSync, user.Timezone) }
Phase 9: Docker Configuration
File: docker-compose.yml
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-integrationto verify no regressions
Deployment Steps
- Update
database/schema/schema.sqlwith new column and settings - Regenerate sqlc:
cd internal/database && sqlc generate - Apply schema changes (restart database container with
make rebuild-force-db) - Deploy backend code changes
- Verify with existing data