package services import ( "bookhoard/internal/database" "bookhoard/internal/utils" "context" "encoding/json" "fmt" "log" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) // StatisticsService aggregates reading_history sessions into the // reading-stats surfaces: the /stats page, its per-book card, and the // dashboard "this week" widget. All day bucketing happens in the // REQUESTING user's timezone — every calendar concept here is per-user // (server convention; "today" is the user's calendar, never UTC). type StatisticsService struct { db *database.Queries } func NewStatisticsService(db *database.Queries) *StatisticsService { return &StatisticsService{db: db} } // finishedThreshold mirrors what the readers push at the end of a book: // 0.995 leaves room for a TOC/colophon page pushing just under 100% // (percentage is a 0..1 float everywhere on the server). const finishedThreshold = 0.995 // streakLookbackDays bounds the streak calendar (both streaks computed // over that recent window — "in record"). const streakLookbackDays = 730 // topBooksLimit is the "most read" list length on the /stats page. const topBooksLimit = 5 // StatsRange is an inclusive calendar range in a user's timezone. type StatsRange struct { Start time.Time // first instant of the first day End time.Time // last nanosecond of the last day Days int } // ResolveStatsRange maps the UI's selector ("7", "30", "90", "year") to // an inclusive window ending today in the user's timezone. func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) { loc := utils.UserLocation(tz) now := time.Now().In(loc) // Anchor the end-of-day to the START of today (clock time stripped): // today.AddDate(0,0,1) minus a nanosecond would carry this instant's // clock into tomorrow and resolve "today" a calendar day late. todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) end := todayStart.AddDate(0, 0, 1).Add(-time.Nanosecond) var days int switch rangeKey { case "", "7": days = 7 case "30": days = 30 case "90": days = 90 case "year": // Calendar year to date (Jan 1 → today). jan1 := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) days = int(now.Sub(jan1).Hours()/24.0) + 1 if days < 1 { days = 1 } default: return StatsRange{}, fmt.Errorf("invalid range: %s (use 7, 30, 90 or year)", rangeKey) } start := todayStart.AddDate(0, 0, -(days - 1)) return StatsRange{Start: start, End: end, Days: days}, nil } type DailyStats struct { Date string `json:"date"` // YYYY-MM-DD, in the user's timezone Minutes int `json:"minutes"` Pages int `json:"pages"` Sessions int `json:"sessions"` } type StreakStats struct { // Current counts consecutive active days ending at today; when today // has no session yet, the streak that ends yesterday still counts. Current int `json:"current"` Longest int `json:"longest"` // longest active streak in record ActiveToday bool `json:"active_today"` } type TopBookStats struct { MediaItemID string `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` Minutes int `json:"minutes"` Pages int `json:"pages"` Sessions int `json:"sessions"` LastRead string `json:"last_read"` } type LibraryStats struct { LibraryID string `json:"library_id"` Name string `json:"name"` Minutes int `json:"minutes"` Pages int `json:"pages"` Sessions int `json:"sessions"` Books int `json:"books"` } type DeviceStats struct { // DeviceID is empty for the web bucket (pushes that carry no device // credential — browser reading sessions) — Name then reads "Web". DeviceID string `json:"device_id"` Name string `json:"name"` Type string `json:"type"` Minutes int `json:"minutes"` Pages int `json:"pages"` Sessions int `json:"sessions"` Books int `json:"books"` } type UserSummaryStats struct { Range string `json:"range"` Days int `json:"days"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` TotalMinutes int `json:"total_minutes"` TotalPages int `json:"total_pages"` SessionCount int `json:"session_count"` BooksActive int `json:"books_with_activity"` BooksFinished int `json:"books_finished"` CompletionRate float64 `json:"completion_rate"` // finished / books with activity (0 when none) AvgSessionMin float64 `json:"average_session_minutes"` Daily []DailyStats `json:"daily"` // zero-filled across the range Streak StreakStats `json:"streak"` MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing MostActiveDay string `json:"most_active_day"` // date with the most minutes MostActiveMinutes int `json:"most_active_day_minutes"` TopBooks []TopBookStats `json:"top_books"` LibraryBreakdown []LibraryStats `json:"library_breakdown"` DeviceBreakdown []DeviceStats `json:"device_breakdown"` } type WeekSummaryStats struct { WeekMinutes int `json:"week_minutes"` WeekPages int `json:"week_pages"` WeekSessions int `json:"week_sessions"` WeekBooks int `json:"week_books"` PrevMinutes int `json:"prev_week_minutes"` PrevPages int `json:"prev_week_pages"` PrevSessions int `json:"prev_week_sessions"` ActiveToday bool `json:"active_today"` CurrentStreak int `json:"current_streak"` } type BookStats struct { MediaItemID string `json:"media_item_id"` TotalMinutes int `json:"total_minutes"` TotalSeconds int `json:"total_seconds"` // exact wall-clock (audio "Time listened"; minutes lose the remainder) TotalPages int `json:"total_pages"` SessionCount int `json:"session_count"` MaxProgress float64 `json:"max_progress"` Finished bool `json:"finished"` FirstSession string `json:"first_session"` LastSession string `json:"last_session"` RecentSessions []SessionSummary `json:"recent_sessions"` // newest first, capped } type SessionSummary struct { StartedAt string `json:"started_at"` EndedAt string `json:"ended_at"` Minutes int `json:"minutes"` Pages int `json:"pages"` // 0 when unknown (no page info on the pushes) Progress float64 `json:"progress"` Source string `json:"source"` } // userVisibleLibraryIDs loads the family-lens set for this user (empty → // no visible-library filter, i.e. everything is visible by default). func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { rows, err := s.db.GetUserVisibleLibraries(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to check library access: %w", err) } ids := make([]pgtype.UUID, 0, len(rows)) for _, lib := range rows { if lib.ID.Valid { ids = append(ids, lib.ID) } } return ids, nil } // visibleOrNil maps an empty visible set to a NULL filter argument. func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID { if len(ids) == 0 { return nil } return ids } // UserSummary builds the /stats page payload for a range: totals, // zero-filled daily arrays, streaks, most-active day, top books, and // the per-library breakdown — one call for the page. func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, tz, rangeKey string) (UserSummaryStats, error) { statsRange, err := ResolveStatsRange(tz, rangeKey) if err != nil { return UserSummaryStats{}, err } loc := utils.UserLocation(tz) visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID) if err != nil { return UserSummaryStats{}, err } sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{ UserID: userID, StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true}, EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true}, VisibleLibraryIds: visibleOrNil(visibleIDs), }) if err != nil { return UserSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err) } summary := UserSummaryStats{ Range: rangeKey, Days: statsRange.Days, StartDate: statsRange.Start.Format("2006-01-02"), EndDate: statsRange.End.Format("2006-01-02"), Daily: make([]DailyStats, 0, statsRange.Days), TopBooks: []TopBookStats{}, LibraryBreakdown: []LibraryStats{}, DeviceBreakdown: []DeviceStats{}, } // Zero-filled calendar (inclusive of today). dailyMap := make(map[string]*DailyStats, statsRange.Days) for i := 0; i < statsRange.Days; i++ { day := statsRange.Start.AddDate(0, 0, i) entry := DailyStats{Date: day.Format("2006-01-02")} summary.Daily = append(summary.Daily, entry) dailyMap[entry.Date] = &summary.Daily[len(summary.Daily)-1] } bookActivity := make(map[string]bool, len(sessions)) finishedSet := make(map[string]bool) var totalSessionSeconds int64 weekdayMinutes := make(map[time.Weekday]int) for _, sess := range sessions { // The bucket key matches the SQL range filter: the session END // instant (what the capture stamps; NULL falls back to // created_at) — not the row's created_at. ts := sess.CreatedAt if sess.ReadingSessionEnd.Valid { ts = sess.ReadingSessionEnd } key := dayKey(ts, loc) entry, ok := dailyMap[key] if !ok { continue // defensive: outside the inclusive window } if sess.TimeSpentSeconds.Valid { seconds := int64(sess.TimeSpentSeconds.Int32) totalSessionSeconds += seconds summary.TotalMinutes += int(seconds / 60) entry.Minutes += int(seconds / 60) } if sess.PagesRead.Valid { summary.TotalPages += int(sess.PagesRead.Int32) entry.Pages += int(sess.PagesRead.Int32) } entry.Sessions++ summary.SessionCount++ mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16]) if err == nil { key := mediaUUID.String() bookActivity[key] = true if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold { finishedSet[key] = true } } localTime := ts.Time.In(loc) if sess.TimeSpentSeconds.Valid { weekdayMinutes[localTime.Weekday()] += int(sess.TimeSpentSeconds.Int32) / 60 } } summary.BooksActive = len(bookActivity) summary.BooksFinished = len(finishedSet) if summary.BooksActive > 0 { summary.CompletionRate = float64(summary.BooksFinished) / float64(summary.BooksActive) } if summary.SessionCount > 0 { summary.AvgSessionMin = float64(totalSessionSeconds) / float64(summary.SessionCount) / 60.0 } // Most-active weekday by minutes (empty string before any signal). dayNames := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} bestWeekdayMinutes := -1 var bestWeekday time.Weekday for wd, mins := range weekdayMinutes { if mins > bestWeekdayMinutes { bestWeekdayMinutes = mins bestWeekday = wd } } if bestWeekdayMinutes > 0 { summary.MostActiveWeekday = dayNames[bestWeekday] } for _, d := range summary.Daily { if d.Minutes > summary.MostActiveMinutes { summary.MostActiveMinutes = d.Minutes summary.MostActiveDay = d.Date } } // Streaks: a dedicated lightweight calendar (~2y look-back) so a // current streak longer than the selected range is still real. activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{ Tz: loc.String(), UserID: userID, StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true}, VisibleLibraryIds: visibleOrNil(visibleIDs), }) if err == nil { summary.Streak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")) } else { log.Printf("stats: active-days query failed: %v", err) } // Top books by minutes in the range. topRows, err := s.db.GetTopBooksByMinutes(ctx, database.GetTopBooksByMinutesParams{ UserID: userID, StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true}, EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true}, VisibleLibraryIds: visibleOrNil(visibleIDs), Limit: topBooksLimit, }) if err == nil { for _, row := range topRows { entry := TopBookStats{ Title: row.Title, Author: row.Author.String, CoverImagePath: utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath), Minutes: int(row.TotalSeconds) / 60, Pages: int(row.TotalPages), Sessions: int(row.SessionCount), } if row.MediaItemID.Valid { entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String() } if row.LastRead != nil { if t, ok := row.LastRead.(time.Time); ok { entry.LastRead = utils.FormatTimeInTimezone(t, tz) } } summary.TopBooks = append(summary.TopBooks, entry) } } // Per-library breakdown in the range. libRows, err := s.db.GetUserLibraryBreakdown(ctx, database.GetUserLibraryBreakdownParams{ UserID: userID, StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true}, EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true}, VisibleLibraryIds: visibleOrNil(visibleIDs), }) if err == nil { for _, row := range libRows { entry := LibraryStats{ Name: row.Name, Minutes: int(row.TotalSeconds) / 60, Pages: int(row.TotalPages), Sessions: int(row.SessionCount), Books: int(row.BookCount), } if row.LibraryID.Valid { entry.LibraryID = uuid.UUID(row.LibraryID.Bytes).String() } summary.LibraryBreakdown = append(summary.LibraryBreakdown, entry) } } // Per-device breakdown in the range (LEFT JOIN devices: NULL-device // pushes aggregate into one unattributed row so the panel reconciles // with the totals). deviceRows, err := s.db.GetUserDeviceBreakdown(ctx, database.GetUserDeviceBreakdownParams{ UserID: userID, StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true}, EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true}, VisibleLibraryIds: visibleOrNil(visibleIDs), }) if err == nil { for _, row := range deviceRows { entry := DeviceStats{ Minutes: int(row.TotalSeconds) / 60, Pages: int(row.TotalPages), Sessions: int(row.SessionCount), Books: int(row.BookCount), Name: "Web", Type: "web", } if row.DeviceName.Valid && row.DeviceName.String != "" { entry.Name = row.DeviceName.String } if row.DeviceType.Valid && row.DeviceType.String != "" { entry.Type = row.DeviceType.String } if row.ID.Valid { entry.DeviceID = uuid.UUID(row.ID.Bytes).String() } summary.DeviceBreakdown = append(summary.DeviceBreakdown, entry) } } return summary, nil } // ThisWeek builds the dashboard widget: the current rolling 7-day // window (today inclusive) versus the previous seven days, plus the // streak context. One scan over 14 days, split in Go. libraryID scopes // everything to one library (invalid = all visible libraries) so the // widget tracks the dashboard's library switcher. func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz string, libraryID pgtype.UUID) (WeekSummaryStats, error) { statsRange, err := ResolveStatsRange(tz, "7") if err != nil { return WeekSummaryStats{}, err } loc := utils.UserLocation(tz) visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID) if err != nil { return WeekSummaryStats{}, err } // Scan the 14-day window: [start - 7 days, end]. scanStart := statsRange.Start.AddDate(0, 0, -7) sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{ UserID: userID, StartTime: pgtype.Timestamptz{Time: scanStart, Valid: true}, EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true}, LibraryID: libraryID, VisibleLibraryIds: visibleOrNil(visibleIDs), }) if err != nil { return WeekSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err) } week := map[string]bool{statsRange.Start.Format("2006-01-02"): true} for i := 0; i < statsRange.Days; i++ { week[statsRange.Start.AddDate(0, 0, i).Format("2006-01-02")] = true } out := WeekSummaryStats{ ActiveToday: week[statsRange.End.Format("2006-01-02")], CurrentStreak: 0, } booksThis, booksPrev := make(map[string]bool), make(map[string]bool) for _, sess := range sessions { // Same bucket key as UserSummary: the session END instant. ts := sess.CreatedAt if sess.ReadingSessionEnd.Valid { ts = sess.ReadingSessionEnd } key := dayKey(ts, loc) minutes := 0 if sess.TimeSpentSeconds.Valid { minutes = int(sess.TimeSpentSeconds.Int32) / 60 } pages := 0 if sess.PagesRead.Valid { pages = int(sess.PagesRead.Int32) } mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16]) var bookKey string if err == nil { bookKey = mediaUUID.String() } if week[key] { // current 7 days out.WeekMinutes += minutes out.WeekPages += pages out.WeekSessions++ if bookKey != "" { booksThis[bookKey] = true } } else { // previous 7 days out.PrevMinutes += minutes out.PrevPages += pages out.PrevSessions++ if bookKey != "" { booksPrev[bookKey] = true } } } out.WeekBooks = len(booksThis) _ = booksPrev activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{ Tz: loc.String(), UserID: userID, StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true}, LibraryID: libraryID, VisibleLibraryIds: visibleOrNil(visibleIDs), }) if err == nil { out.CurrentStreak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).Current out.ActiveToday = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).ActiveToday } return out, nil } // BookStats is the per-book card: lifetime totals + the most recent // sessions (bounded so the detail page never scans unbounded history). func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) { sessions, err := s.db.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{ UserID: userID, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, }) if err != nil { return BookStats{}, fmt.Errorf("failed to load book sessions: %w", err) } out := BookStats{ MediaItemID: mediaItemID.String(), RecentSessions: []SessionSummary{}, } for i, sess := range sessions { if sess.TimeSpentSeconds.Valid { out.TotalSeconds += int(sess.TimeSpentSeconds.Int32) out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60 } if sess.PagesRead.Valid { out.TotalPages += int(sess.PagesRead.Int32) } out.SessionCount++ if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 > out.MaxProgress { out.MaxProgress = sess.ProgressPercentage.Float64 } out.Finished = out.Finished || (sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold) start := "" if sess.ReadingSessionStart.Valid { start = utils.FormatTimeInTimezone(sess.ReadingSessionStart.Time, tz) } end := "" if sess.ReadingSessionEnd.Valid { end = utils.FormatTimeInTimezone(sess.ReadingSessionEnd.Time, tz) } if sess.ReadingSessionEnd.Valid { if i == 0 { out.FirstSession = end } out.LastSession = end // ascending order → last assignment wins } if i >= len(sessions)-10 { // the most recent 10 sessions only summary := SessionSummary{ StartedAt: start, EndedAt: end, Minutes: int(sess.TimeSpentSeconds.Int32) / 60, Source: metadataSource(sess.DeviceMetadata), } if sess.PagesRead.Valid { summary.Pages = int(sess.PagesRead.Int32) } if sess.ProgressPercentage.Valid { summary.Progress = sess.ProgressPercentage.Float64 } out.RecentSessions = append(out.RecentSessions, summary) } } // Most-recent first for display. for i, j := 0, len(out.RecentSessions)-1; i < j; i, j = i+1, j-1 { out.RecentSessions[i], out.RecentSessions[j] = out.RecentSessions[j], out.RecentSessions[i] } return out, nil } // computeStreaks walks an ascending ISO-date list (the active-day // calendar in the user's timezone) anchored at TODAY's date. func computeStreaks(activeDays []string, today string) StreakStats { activeSet := make(map[string]bool, len(activeDays)) for _, d := range activeDays { activeSet[d] = true } out := StreakStats{ActiveToday: activeSet[today], Current: 0, Longest: 0} // Current: walk back from today; an inactive TODAY falls through to // yesterday so a not-yet-read day doesn't erase a live streak. anchor := today if !activeSet[anchor] { if d, err := time.Parse("2006-01-02", today); err == nil { anchor = d.AddDate(0, 0, -1).Format("2006-01-02") } } for cur := anchor; activeSet[cur]; { out.Current++ d, err := time.Parse("2006-01-02", cur) if err != nil { break } cur = d.AddDate(0, 0, -1).Format("2006-01-02") } // Longest run in the ascending ordered list. run := 0 var prev string for _, d := range activeDays { if prev != "" && nextDay(prev) == d { run++ } else { run = 1 } if run > out.Longest { out.Longest = run } prev = d } return out } // nextDay returns the day after an ISO date, preserving the format. func nextDay(iso string) string { d, err := time.Parse("2006-01-02", iso) if err != nil { return "" } return d.AddDate(0, 0, 1).Format("2006-01-02") } // dayKey buckets a session instant into the user's calendar date. func dayKey(ts pgtype.Timestamptz, loc *time.Location) string { if !ts.Valid { return "" } return ts.Time.In(loc).Format("2006-01-02") } // metadataSource extracts the capture's source tag from device_metadata. func metadataSource(raw []byte) string { if len(raw) == 0 { return "" } var meta struct { Source string `json:"source"` } if jsonErr := json.Unmarshal(raw, &meta); jsonErr == nil { return meta.Source } return "" }