package handlers import ( "bookhoard/internal/database" "context" "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") if startDate == "" { startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02") } if endDate == "" { endDate = time.Now().Format("2006-01-02") } startTime, err := time.Parse("2006-01-02", startDate) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format") } endTime, err := time.Parse("2006-01-02", endDate) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format") } 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 && err != pgx.ErrNoRows { return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history") } stats := h.calculateReadingStats(history) return c.JSON(http.StatusOK, stats) } func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow) 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.Format("2006-01-02") 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.Format("2006-01-02") 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) for _, data := range dailyMap { stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data) } return stats } func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow) 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()] 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 && 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 = t.Format("2006-01-02 15:04:05") } } 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 && 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 = t.Format("2006-01-02 15:04:05") } } 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() }