fix(tz): server-side timezone conversion for all user-facing time displays

The audit showed only 5 of ~11 time-display surfaces converted to the
user's timezone; everything else rendered pgx-decoded UTC. The server
owns conversion — clients (web/app/KOReader) receive display-ready
strings or machine-readable instants and never convert.

- Progress page: LastUpdated formatted in the requesting user's tz
  (was raw UTC).
- Analytics: date-range defaults, day buckets, most-active-day, device
  last-sync, and popular-book last-read all computed in the user's tz —
  evening sessions no longer land on the next UTC day. Also fixed the
  end-date boundary: the query bounds with <= and the end bound was
  midnight, so the end date's own sessions (including 'today') never
  appeared.
- Queue page: new created_at_display field (admin tz) rendered by the
  template; created_at stays RFC3339 for API consumers.
- Admin hash-conflicts SSR bridge: detected date rendered in the
  admin's tz.
- OPDS: .UTC() before the Z-suffixed feed timestamps (honesty fix —
  correct today only because the session zone happens to be UTC).
- KOReader annotation datetimes deliberately untouched (device owns its
  display).
This commit is contained in:
John O'Keefe committed 2026-10-13 13:59:49 -04:00
1 parent 740213f0a4
commit 1601548e6c
8 files changed
+75 -24

No files matched your search

+24 -13
View File
@@ -2,6 +2,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
@@ -74,22 +75,32 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
startDate := c.QueryParam("start_date")
endDate := c.QueryParam("end_date")
// All range boundaries and day buckets are computed in the REQUESTING
// USER'S timezone: "today" and "which day did this session land on" are
// per-user calendar concepts, not UTC ones.
loc := utils.UserLocation(user.Timezone.String)
now := time.Now().In(loc)
if startDate == "" {
startDate = time.Now().AddDate(0, -1, 0).Format("01-02-2006")
startDate = now.AddDate(0, -1, 0).Format("01-02-2006")
}
if endDate == "" {
endDate = time.Now().Format("01-02-2006")
endDate = now.Format("01-02-2006")
}
startTime, err := time.Parse("01-02-2006", startDate)
startTime, err := time.ParseInLocation("01-02-2006", startDate, loc)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
}
endTime, err := time.Parse("01-02-2006", endDate)
endTime, err := time.ParseInLocation("01-02-2006", endDate, loc)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
}
// The query bounds with <=, so the end bound must be the last instant
// OF the end date — otherwise the end date's own sessions (including
// "today") never appear.
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
ctx := context.Background()
@@ -103,12 +114,12 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
}
stats := h.calculateReadingStats(history)
stats := h.calculateReadingStats(history, loc)
return c.JSON(http.StatusOK, stats)
}
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow) ReadingStatsResponse {
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow, loc *time.Location) ReadingStatsResponse {
stats := ReadingStatsResponse{
DailyReadingMinutes: make([]DailyReading, 0),
}
@@ -130,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
longestSession = int(minutes)
}
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
if dailyMap[dateKey] == nil {
dailyMap[dateKey] = &DailyReading{
Date: dateKey,
@@ -141,7 +152,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
if entry.PagesRead.Valid {
totalPages += int(entry.PagesRead.Int32)
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
if dailyMap[dateKey] != nil {
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
}
@@ -174,7 +185,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
stats.CompletionRate = float64(booksCompleted) / float64(totalBooks)
}
stats.MostActiveDay = h.calculateMostActiveDay(history)
stats.MostActiveDay = h.calculateMostActiveDay(history, loc)
for _, data := range dailyMap {
stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data)
@@ -183,13 +194,13 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
return stats
}
func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow) string {
func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow, loc *time.Location) string {
dayCount := make(map[string]int)
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
for _, entry := range history {
day := days[entry.CreatedAt.Time.Weekday()]
day := days[entry.CreatedAt.Time.In(loc).Weekday()]
dayCount[day]++
}
@@ -222,7 +233,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
lastSync := ""
if u.LastSync != nil {
if t, ok := u.LastSync.(time.Time); ok {
lastSync = t.Format("01-02-2006 03:04:05 PM")
lastSync = utils.FormatTimeInTimezone(t, user.Timezone.String)
}
}
@@ -274,7 +285,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
lastRead := ""
if book.LastRead != nil {
if t, ok := book.LastRead.(time.Time); ok {
lastRead = t.Format("01-02-2006 03:04:05 PM")
lastRead = utils.FormatTimeInTimezone(t, user.Timezone.String)
}
}