Update all handler functions to use *echo.Context (pointer) instead of echo.Context (value) as required by Echo v5. Changes across all handler files: - analytics.go: Update handler signatures - auth.go: Update authentication handler signatures - book_matching.go: Update matching handler signatures - collections.go: Update collection handler signatures - collections_preview_test.go: Update test signatures - commonhandlers.go: Update common handler signatures - conflicts.go: Update conflict handler signatures - context.go: Update context handler signatures - dashboard.go: Update dashboard handler signatures - devices.go: Update device handler signatures - jobs.go: Update job handler signatures - kobo.go: Update Kobo handler signatures - koreader.go: Update Koreader handler signatures - library.go: Update library handler signatures - matching.go: Update matching handler signatures - media.go: Update media handler signatures - opds.go: Update OPDS handler signatures - progress.go: Update progress handler signatures - queue.go: Update queue handler signatures - refresh_token.go: Update token handler signatures - scanner.go: Update scanner handler signatures - sidecar.go: Update sidecar handler signatures - sync.go: Update sync handler signatures - system_settings.go: Update settings handler signatures - websocket.go: Update WebSocket handler signatures All handlers now properly implement Echo v5's pointer-based context pattern. This change is necessary for type safety and compatibility with Echo v5's improved context handling and WebSocket support.
310 lines
7.7 KiB
Go
310 lines
7.7 KiB
Go
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()
|
|
}
|