feat(stats): reading statistics — session capture wired + /stats page + widget + per-book card
The design parked in the handoff (multi-tier stats off reading_history; bookorbit-style in data, not looks) is in. The real prerequisite work was data: reading_history existed but NOTHING wrote to it — the whole stats feature was sitting on an empty table. - CAPTURE (no client changes, no repo-boundary crossings): every successful progress write through SaveProgress (KOReader + app + web + Kobo + offline queue — the one chokepoint) now records one reading_history session row: span = the interval since the PREVIOUS write instant, pages = positive page delta when both sides know the page index, marker rows for first contact. Spans over 2h are kept as zero-second markers (overnight suspend→resume, offline flushes must not inflate totals) — the honest 'sync cadence, not a stopwatch' caveat stays. device_id only when a REAL device row matches (the web/app progress path historically stuffs the USER id into DeviceID — probe instead of trusting it; FK violation would silently drop every capture). Source preserved in device_metadata. - API: /api/stats/summary?range=7|30|90|year (totals, zero-filled daily arrays, streaks, most-active day/weekday, top books, library breakdown — one call), /api/stats/this-week (widget: rolling 7d vs previous + streak), /api/stats/books/:id (lifetime + recent sessions). All bucketed in the user's timezone; finished ≡ any push ≥0.995; visible-library lens enforced at source. Legacy /api/analytics/* kept untouched. Range windows anchor end-of-day to the START of today (blasting the clock time into tomorrow resolved 'today' a calendar day late — found by the streak test). - /stats page (sidebar Stats between Progress and Devices, previously /analytics was an orphan): range selector, five stat cards, two Chart.js v4 charts (daily minutes bar, pages/day line — chunked via dynamic import so main.js stays untouched), per-library breakdown, most-read list, empty-state explainer. /analytics now 301s to /stats. The orphaned analytics.templ + analytics.ts removed (their containers and the page JSON never matched; the page was astub). - Dashboard 'This Week' widget (server-rendered, suppressed on service failure) and the book detail 'Your Reading Stats' card (lifetime totals of the viewer's own sessions) complete the tiers: per-user default, per-library breakdown inline, per-book on the page, per-system/admin aggregate parked as before. Tests: statistics_test.go (capture semantics at the chokepoint, tz/day buckets, zero-fill, streak math, finished detection, range validation, auth gate, widget, per-book lifetime). Full suite PASS.
This commit is contained in:
1 parent
8de8883acf
commit
6457fbaf72
28 files changed
+3061
-814
No files matched your search
@@ -340,9 +340,9 @@ func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID,
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
@@ -374,9 +374,9 @@ func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uui
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
@@ -410,9 +410,9 @@ func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UU
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
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 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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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{},
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz string) (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},
|
||||
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},
|
||||
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.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 ""
|
||||
}
|
||||
Reference in new issue
Block a user