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:
1 parent
740213f0a4
commit
1601548e6c
8 files changed
+75
-24
No files matched your search
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -74,22 +75,32 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
|||||||
startDate := c.QueryParam("start_date")
|
startDate := c.QueryParam("start_date")
|
||||||
endDate := c.QueryParam("end_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 == "" {
|
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 == "" {
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
|
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()
|
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")
|
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)
|
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{
|
stats := ReadingStatsResponse{
|
||||||
DailyReadingMinutes: make([]DailyReading, 0),
|
DailyReadingMinutes: make([]DailyReading, 0),
|
||||||
}
|
}
|
||||||
@@ -130,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
|||||||
longestSession = int(minutes)
|
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 {
|
if dailyMap[dateKey] == nil {
|
||||||
dailyMap[dateKey] = &DailyReading{
|
dailyMap[dateKey] = &DailyReading{
|
||||||
Date: dateKey,
|
Date: dateKey,
|
||||||
@@ -141,7 +152,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
|||||||
|
|
||||||
if entry.PagesRead.Valid {
|
if entry.PagesRead.Valid {
|
||||||
totalPages += int(entry.PagesRead.Int32)
|
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 {
|
if dailyMap[dateKey] != nil {
|
||||||
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
|
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.CompletionRate = float64(booksCompleted) / float64(totalBooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.MostActiveDay = h.calculateMostActiveDay(history)
|
stats.MostActiveDay = h.calculateMostActiveDay(history, loc)
|
||||||
|
|
||||||
for _, data := range dailyMap {
|
for _, data := range dailyMap {
|
||||||
stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data)
|
stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data)
|
||||||
@@ -183,13 +194,13 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
|||||||
return stats
|
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)
|
dayCount := make(map[string]int)
|
||||||
|
|
||||||
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||||
|
|
||||||
for _, entry := range history {
|
for _, entry := range history {
|
||||||
day := days[entry.CreatedAt.Time.Weekday()]
|
day := days[entry.CreatedAt.Time.In(loc).Weekday()]
|
||||||
dayCount[day]++
|
dayCount[day]++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +233,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
|||||||
lastSync := ""
|
lastSync := ""
|
||||||
if u.LastSync != nil {
|
if u.LastSync != nil {
|
||||||
if t, ok := u.LastSync.(time.Time); ok {
|
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 := ""
|
lastRead := ""
|
||||||
if book.LastRead != nil {
|
if book.LastRead != nil {
|
||||||
if t, ok := book.LastRead.(time.Time); ok {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed
|
|||||||
author = item.Author.String
|
author = item.Author.String
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
|
||||||
entry := opds.NewEntry(
|
entry := opds.NewEntry(
|
||||||
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
||||||
@@ -706,7 +706,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
author = item.Author.String
|
author = item.Author.String
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
|
||||||
entry := opds.NewEntry(
|
entry := opds.NewEntry(
|
||||||
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
|
|||||||
|
|
||||||
lastUpdated := ""
|
lastUpdated := ""
|
||||||
if progress.LastReadAt.Valid {
|
if progress.LastReadAt.Valid {
|
||||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
progressList = append(progressList, ProgressWithMedia{
|
progressList = append(progressList, ProgressWithMedia{
|
||||||
@@ -261,7 +261,7 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
|||||||
|
|
||||||
lastUpdated := ""
|
lastUpdated := ""
|
||||||
if progress.LastReadAt.Valid {
|
if progress.LastReadAt.Valid {
|
||||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
progressList = append(progressList, ProgressWithMedia{
|
progressList = append(progressList, ProgressWithMedia{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -47,7 +48,11 @@ type QueueItemResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ErrorMessage *string `json:"error_message,omitempty"`
|
ErrorMessage *string `json:"error_message,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
ProcessedAt *string `json:"processed_at,omitempty"`
|
// CreatedAtDisplay is the server-rendered wall-clock string in the
|
||||||
|
// requesting admin's timezone (templates render this; created_at stays
|
||||||
|
// machine-readable RFC3339 for API consumers).
|
||||||
|
CreatedAtDisplay string `json:"created_at_display,omitempty"`
|
||||||
|
ProcessedAt *string `json:"processed_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *QueueHandler) GetDeviceQueueStats(c *echo.Context) error {
|
func (h *QueueHandler) GetDeviceQueueStats(c *echo.Context) error {
|
||||||
@@ -136,7 +141,9 @@ func (h *QueueHandler) ListDeviceQueueItems(c *echo.Context) error {
|
|||||||
Status: item.Status.String,
|
Status: item.Status.String,
|
||||||
ErrorMessage: textPtrToString(item.ErrorMessage),
|
ErrorMessage: textPtrToString(item.ErrorMessage),
|
||||||
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
CreatedAtDisplay: utils.FormatTimeInTimezone(
|
||||||
|
item.CreatedAt.Time, user.Timezone.String),
|
||||||
|
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +196,9 @@ func (h *QueueHandler) GetQueueData(c *echo.Context) ([]QueueItemResponse, error
|
|||||||
Status: item.Status.String,
|
Status: item.Status.String,
|
||||||
ErrorMessage: textPtrToString(item.ErrorMessage),
|
ErrorMessage: textPtrToString(item.ErrorMessage),
|
||||||
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
CreatedAtDisplay: utils.FormatTimeInTimezone(
|
||||||
|
item.CreatedAt.Time, user.Timezone.String),
|
||||||
|
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1071,7 +1071,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
LibraryName: p.LibraryName,
|
LibraryName: p.LibraryName,
|
||||||
SHA256: p.FileSha256,
|
SHA256: p.FileSha256,
|
||||||
SHAShort: p.FileSha256[:16] + "…",
|
SHAShort: p.FileSha256[:16] + "…",
|
||||||
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
|
CreatedAt: p.CreatedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006"),
|
||||||
Items: []templates.HashConflictItemData{},
|
Items: []templates.HashConflictItemData{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserLocation resolves an IANA timezone name to a *time.Location, falling
|
||||||
|
// back to UTC for empty or invalid values. The single resolution point for
|
||||||
|
// server-side display conversion: the server owns timezone rendering, so
|
||||||
|
// clients receive display-ready strings (or tz-aware instants) and never
|
||||||
|
// convert themselves.
|
||||||
|
func UserLocation(timezone string) *time.Location {
|
||||||
|
if timezone == "" {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(timezone)
|
||||||
|
if err != nil || loc == nil {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTimeInTimezone renders t in the given IANA timezone as
|
||||||
|
// MM-DD-YYYY HH:MM AM/PM (the established display format across the
|
||||||
|
// templates). Zero times render empty.
|
||||||
|
func FormatTimeInTimezone(t time.Time, timezone string) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.In(UserLocation(timezone)).Format("01-02-2006 03:04 PM")
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center text-sm" style="color: var(--text-secondary)">
|
<div class="flex justify-between items-center text-sm" style="color: var(--text-secondary)">
|
||||||
<span>Attempts: { item.Attempts }/{ item.MaxAttempts }</span>
|
<span>Attempts: { item.Attempts }/{ item.MaxAttempts }</span>
|
||||||
<span>{ item.CreatedAt }</span>
|
<span>{ item.CreatedAtDisplay }</span>
|
||||||
</div>
|
</div>
|
||||||
if item.ErrorMessage != nil {
|
if item.ErrorMessage != nil {
|
||||||
<div class="mt-2 text-sm flex items-start gap-2" style="color: var(--status-danger);">
|
<div class="mt-2 text-sm flex items-start gap-2" style="color: var(--status-danger);">
|
||||||
|
|||||||
@@ -244,9 +244,9 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.CreatedAt)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.CreatedAtDisplay)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 143, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 143, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
Reference in new issue
Block a user