feat(analytics): add reading statistics dashboard
- Add reading stats endpoint with daily/monthly history - Add device usage statistics (sync count, time spent) - Add popular books view with completion rates - Server-side rendered analytics page with HTMX - Date range filtering for reading history
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/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/v4"
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user