diff --git a/internal/handlers/analytics.go b/internal/handlers/analytics.go new file mode 100644 index 0000000..7372127 --- /dev/null +++ b/internal/handlers/analytics.go @@ -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() +} diff --git a/templates/analytics.templ b/templates/analytics.templ new file mode 100644 index 0000000..21bf86c --- /dev/null +++ b/templates/analytics.templ @@ -0,0 +1,109 @@ +package templates + +templ Analytics(user User) { + + + + + + Reading Analytics - Bookmann + + + + + + + + + @Header(user, "/analytics") + +
+
+

📊 Reading Analytics

+

Track your reading habits and device usage

+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+

Books Read

+

-

+
+
+

Pages Read

+

-

+
+
+

Reading Time

+

-

+
+
+

Completion Rate

+

-

+
+
+ + +
+ +
+

Daily Reading Minutes

+
+ +
+
+ + +
+

Device Usage

+
+ +
+
+
+ + +
+

Most Read Books

+ +
+
+ + + + +} diff --git a/templates/analytics_templ.go b/templates/analytics_templ.go new file mode 100644 index 0000000..cdbfaa3 --- /dev/null +++ b/templates/analytics_templ.go @@ -0,0 +1,48 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Analytics(user User) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Analytics - Bookmann") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(user, "/analytics").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

📊 Reading Analytics

Track your reading habits and device usage

Books Read

-

Pages Read

-

Reading Time

-

Completion Rate

-

Daily Reading Minutes

Device Usage

Most Read Books

Loading analytics...

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate