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.
This commit is contained in:
@@ -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
|
||||
<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:
|
||||
|
||||
```templ
|
||||
<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
|
||||
|
||||
```templ
|
||||
<!-- Before -->
|
||||
{ book.ReadingProgress.LastReadAt.Time.Format("2006-01-02 15:04") }
|
||||
|
||||
<!-- After -->
|
||||
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
|
||||
```
|
||||
|
||||
For `time.Time` fields:
|
||||
|
||||
```templ
|
||||
<!-- Before -->
|
||||
{ device.LastSync.Format("2006-01-02 15:04") }
|
||||
|
||||
<!-- After -->
|
||||
{ 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
|
||||
Reference in New Issue
Block a user