diff --git a/internal/handlers/analytics.go b/internal/handlers/analytics.go index 1b0644ee..365fa0dd 100644 --- a/internal/handlers/analytics.go +++ b/internal/handlers/analytics.go @@ -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) } } diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index a417f389..af9308d2 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -402,7 +402,7 @@ func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed 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( fmt.Sprintf("urn:uuid:%s", bookUUID), @@ -706,7 +706,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error { 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( fmt.Sprintf("urn:uuid:%s", bookUUID), diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go index a4e07219..176525ad 100644 --- a/internal/handlers/progress.go +++ b/internal/handlers/progress.go @@ -191,7 +191,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error { lastUpdated := "" 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{ @@ -261,7 +261,7 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro lastUpdated := "" 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{ diff --git a/internal/handlers/queue.go b/internal/handlers/queue.go index c6b02a29..71000fa8 100644 --- a/internal/handlers/queue.go +++ b/internal/handlers/queue.go @@ -2,6 +2,7 @@ package handlers import ( "bookhoard/internal/database" + "bookhoard/internal/utils" "context" "net/http" "strconv" @@ -47,7 +48,11 @@ type QueueItemResponse struct { Status string `json:"status"` ErrorMessage *string `json:"error_message,omitempty"` 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 { @@ -136,7 +141,9 @@ func (h *QueueHandler) ListDeviceQueueItems(c *echo.Context) error { Status: item.Status.String, ErrorMessage: textPtrToString(item.ErrorMessage), 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, ErrorMessage: textPtrToString(item.ErrorMessage), 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), }) } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 2a87be06..5e64d308 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -1071,7 +1071,7 @@ func registerFrontendRoutes(cfg *Config) { LibraryName: p.LibraryName, SHA256: p.FileSha256, 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{}, } diff --git a/internal/utils/timeformat.go b/internal/utils/timeformat.go new file mode 100644 index 00000000..5f3ce564 --- /dev/null +++ b/internal/utils/timeformat.go @@ -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") +} diff --git a/templates/queue.templ b/templates/queue.templ index 5f5ca165..b5242a15 100644 --- a/templates/queue.templ +++ b/templates/queue.templ @@ -140,7 +140,7 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q