Files
bookhoard/internal/handlers/analytics.go
T
John O'Keefe 1601548e6c 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).
2026-10-13 13:59:49 -04:00

322 lines
8.3 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type AnalyticsHandler struct {
db *database.Queries
}
func NewAnalyticsHandler(db *database.Queries) *AnalyticsHandler {
return &AnalyticsHandler{
db: db,
}
}
type ReadingStatsResponse struct {
TotalBooksRead int `json:"total_books_read"`
TotalPagesRead int `json:"total_pages_read"`
TotalReadingTime int `json:"total_reading_time_minutes"`
AverageSessionTime float64 `json:"average_session_time_minutes"`
LongestSession int `json:"longest_session_minutes"`
MostActiveDay string `json:"most_active_day_of_week"`
CompletionRate float64 `json:"completion_rate"`
DailyReadingMinutes []DailyReading `json:"daily_reading_minutes"`
}
type DailyReading struct {
Date string `json:"date"`
Minutes int `json:"minutes"`
Pages int `json:"pages"`
}
type DeviceUsageResponse struct {
Devices []DeviceUsage `json:"devices"`
}
type DeviceUsage struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
SyncCount int `json:"sync_count"`
LastSync string `json:"last_sync"`
TotalTimeSeconds int `json:"total_time_seconds"`
TotalTimeMinutes float64 `json:"total_time_minutes"`
}
type PopularBooksResponse struct {
Books []PopularBook `json:"books"`
}
type PopularBook struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
ReadCount int `json:"read_count"`
AvgCompletion float64 `json:"avg_completion"`
LastRead string `json:"last_read"`
}
func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
user := c.Get("user").(database.Users)
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 = now.AddDate(0, -1, 0).Format("01-02-2006")
}
if endDate == "" {
endDate = now.Format("01-02-2006")
}
startTime, err := time.ParseInLocation("01-02-2006", startDate, loc)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
}
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()
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
UserID: user.ID,
CreatedAt: pgtype.Timestamptz{Time: startTime, Valid: true},
CreatedAt_2: pgtype.Timestamptz{Time: endTime, Valid: true},
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
}
stats := h.calculateReadingStats(history, loc)
return c.JSON(http.StatusOK, stats)
}
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow, loc *time.Location) ReadingStatsResponse {
stats := ReadingStatsResponse{
DailyReadingMinutes: make([]DailyReading, 0),
}
totalTime := 0
totalPages := 0
longestSession := 0
sessionTimes := make([]int, 0)
dailyMap := make(map[string]*DailyReading)
for _, entry := range history {
if entry.TimeSpentSeconds.Valid {
minutes := entry.TimeSpentSeconds.Int32 / 60
totalTime += int(minutes)
sessionTimes = append(sessionTimes, int(minutes))
if int(minutes) > longestSession {
longestSession = int(minutes)
}
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
if dailyMap[dateKey] == nil {
dailyMap[dateKey] = &DailyReading{
Date: dateKey,
}
}
dailyMap[dateKey].Minutes += int(minutes)
}
if entry.PagesRead.Valid {
totalPages += int(entry.PagesRead.Int32)
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
if dailyMap[dateKey] != nil {
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
}
}
}
stats.TotalReadingTime = totalTime
stats.TotalPagesRead = totalPages
stats.LongestSession = longestSession
if len(sessionTimes) > 0 {
sum := 0
for _, t := range sessionTimes {
sum += t
}
stats.AverageSessionTime = float64(sum) / float64(len(sessionTimes))
}
booksCompleted := 0
totalBooks := len(history)
if totalBooks > 0 {
for _, entry := range history {
if entry.ProgressPercentage.Valid && entry.ProgressPercentage.Float64 >= 100.0 {
booksCompleted++
}
}
}
stats.TotalBooksRead = booksCompleted
if totalBooks > 0 {
stats.CompletionRate = float64(booksCompleted) / float64(totalBooks)
}
stats.MostActiveDay = h.calculateMostActiveDay(history, loc)
for _, data := range dailyMap {
stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data)
}
return stats
}
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.In(loc).Weekday()]
dayCount[day]++
}
mostActive := "Monday"
maxCount := 0
for day, count := range dayCount {
if count > maxCount {
maxCount = count
mostActive = day
}
}
return mostActive
}
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
user := c.Get("user").(database.Users)
ctx := context.Background()
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get device usage")
}
devices := make([]DeviceUsage, 0, len(usage))
for _, u := range usage {
lastSync := ""
if u.LastSync != nil {
if t, ok := u.LastSync.(time.Time); ok {
lastSync = utils.FormatTimeInTimezone(t, user.Timezone.String)
}
}
totalTimeMinutes := float64(u.TotalTimeSeconds) / 60.0
devices = append(devices, DeviceUsage{
DeviceID: formatUUID(u.ID),
DeviceName: u.DeviceName,
DeviceType: u.DeviceType,
SyncCount: int(u.SyncCount),
LastSync: lastSync,
TotalTimeSeconds: int(u.TotalTimeSeconds),
TotalTimeMinutes: totalTimeMinutes,
})
}
return c.JSON(http.StatusOK, DeviceUsageResponse{
Devices: devices,
})
}
func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
user := c.Get("user").(database.Users)
limit := c.QueryParam("limit")
if limit == "" {
limit = "10"
}
limitInt := int32(10)
if parsedLimit, err := parseLimit(limit); err == nil {
limitInt = parsedLimit
}
ctx := context.Background()
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
UserID: user.ID,
Limit: limitInt,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get popular books")
}
popularBooks := make([]PopularBook, 0, len(books))
for _, book := range books {
lastRead := ""
if book.LastRead != nil {
if t, ok := book.LastRead.(time.Time); ok {
lastRead = utils.FormatTimeInTimezone(t, user.Timezone.String)
}
}
popularBooks = append(popularBooks, PopularBook{
MediaItemID: formatUUID(book.ID),
Title: book.Title,
Author: book.Author.String,
ReadCount: int(book.ReadCount),
AvgCompletion: book.AvgCompletion,
LastRead: lastRead,
})
}
return c.JSON(http.StatusOK, PopularBooksResponse{
Books: popularBooks,
})
}
func parseLimit(limitStr string) (int32, error) {
var limit int32
if _, err := fmt.Sscanf(limitStr, "%d", &limit); err != nil {
return 0, err
}
return limit, nil
}
func formatUUID(id pgtype.UUID) string {
if !id.Valid {
return ""
}
u := uuid.UUID(id.Bytes)
return u.String()
}