diff --git a/cmd/server/main.go b/cmd/server/main.go index 7f1cc735..7eb991f6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -155,6 +155,8 @@ func main() { wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager) analyticsHandler := handlers.NewAnalyticsHandler(queries) + statisticsService := services.NewStatisticsService(queries) + statsHandler := handlers.NewStatsHandler(statisticsService) queueHandler := handlers.NewQueueHandler(queries, queueProcessor) conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub") @@ -226,6 +228,8 @@ func main() { WSHandler: wsHandler, ConflictHandler: conflictHandler, AnalyticsHandler: analyticsHandler, + StatsHandler: statsHandler, + StatisticsService: statisticsService, QueueHandler: queueHandler, CollectionHandler: collectionHandler, FiltersHandler: filtersHandler, diff --git a/cmd/server/tests/statistics_test.go b/cmd/server/tests/statistics_test.go new file mode 100644 index 00000000..803932e4 --- /dev/null +++ b/cmd/server/tests/statistics_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "bookhoard/internal/database" +) + +// Reading-statistics coverage: session CAPTURE at the sync chokepoint, +// the summary aggregation (tz buckets, streaks, finished detection), +// range validation, the widget, and the per-book card. +// +// All seeded instants anchor to USER-TZ midnights (the test user is UTC) +// so day buckets are deterministic regardless of when the suite runs. + +func pgUser(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte(id), Valid: true} } +func pgFloat(v float64) pgtype.Float8 { return pgtype.Float8{Float64: v, Valid: true} } +func pgInt4(v int32) pgtype.Int4 { return pgtype.Int4{Int32: v, Valid: true} } +func pgTS(v time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: v, Valid: !v.IsZero()} +} + +// middayDaysAgo returns noon UTC N calendar days before today — a +// bucket-stable instant for any wall-clock time the suite runs at. +func middayDaysAgo(daysAgo int) time.Time { + midnight := time.Now().UTC() + midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), 0, 0, 0, 0, time.UTC) + return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour) +} + +func statsLogin(t *testing.T, setup *TestServerSetup, path string) (*http.Response, []byte) { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + t.Setenv("STATS_LAST_BODY_PATH", path) // no-op; keeps body-close ordering explicit + }() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp, body +} + +func TestReadingStatsCapture(t *testing.T) { + setup := setupTestServer(t) + userID := getTestUserID(t, setup.DB) + ctx := context.Background() + + bookID := createTestMediaItemID(t, setup) + + push := func(body map[string]interface{}) int { + payload, _ := json.Marshal(body) + req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode + } + + require.Equal(t, http.StatusOK, push(map[string]interface{}{ + "percentage": 0.10, "current_page": 10, "total_pages": 100, + })) + + time.Sleep(1100 * time.Millisecond) // the second push derives a ~1s span + + require.Equal(t, http.StatusOK, push(map[string]interface{}{ + "percentage": 0.15, "current_page": 15, "total_pages": 100, + })) + + rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{ + UserID: pgUser(userID), + MediaItemID: uuidToPGType(uuid.MustParse(bookID)), + }) + require.NoError(t, err) + require.Len(t, rows, 2, "each push records one session row") + + first := rows[0] + assert.Equal(t, int32(0), first.TimeSpentSeconds.Int32, "first contact: marker with zero time") + assert.InEpsilon(t, 0.10, first.ProgressPercentage.Float64, 0.001) + + second := rows[1] + assert.GreaterOrEqual(t, second.TimeSpentSeconds.Int32, int32(1), "the ~1.1s gap is attributed") + assert.LessOrEqual(t, second.TimeSpentSeconds.Int32, int32(10), "and bounded by the slept interval") + require.True(t, second.PagesRead.Valid, "page info was carried on both pushes") + assert.Equal(t, int32(5), second.PagesRead.Int32, "pages: 15 − 10") + + progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{ + MediaItemID: uuidToPGType(uuid.MustParse(bookID)), + UserID: pgUser(userID), + }) + require.NoError(t, err) + assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position") +} + +func TestReadingStatsSummary(t *testing.T) { + setup := setupTestServer(t) + userID := getTestUserID(t, setup.DB) + ctx := context.Context(context.Background()) + + libIDStr := createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Stats Library %d", time.Now().UnixNano()), "ebooks") + libUUID := uuid.MustParse(libIDStr) + + var finishedBookID, offRangeBookID string + insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, reuseMediaID ...string) string { + var media database.MediaItems + var err error + if len(reuseMediaID) > 0 && reuseMediaID[0] != "" { + media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0]))) + require.NoError(t, err, "reuse seeded media item") + } else { + media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true}, + Title: title, + FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err, "seed media item") + } + + pushAt := middayDaysAgo(daysAgo) + _, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{ + UserID: pgUser(userID), + MediaItemID: media.ID, + ProgressPercentage: pgFloat(pct), + ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)), + ReadingSessionEnd: pgTS(pushAt), + PagesRead: pgInt4(pages), + TimeSpentSeconds: pgInt4(minutes * 60), + DeviceMetadata: []byte(`{"source":"test"}`), + }) + require.NoError(t, err, "seed session") + + id, _ := uuid.FromBytes(media.ID.Bytes[0:16]) + return id.String() + } + + finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book") + insertSession(1, 45, 20, 0.5, "Stats Streak Book") + insertSession(2, 60, 25, 0.42, "Stats Back Book") + insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", finishedBookID) + offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book") + + // Summary ----------------------------------------------------------- + resp, body := statsLogin(t, setup, "/api/stats/summary?range=7") + require.Equal(t, http.StatusOK, resp.StatusCode) + + var summary struct { + Range string `json:"range"` + 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"` + Daily []struct { + Date string `json:"date"` + Minutes int `json:"minutes"` + Pages int `json:"pages"` + } `json:"daily"` + Streak struct { + Current int `json:"current"` + Longest int `json:"longest"` + ActiveToday bool `json:"active_today"` + } `json:"streak"` + TopBooks []struct { + MediaItemID string `json:"media_item_id"` + Minutes int `json:"minutes"` + } `json:"top_books"` + LibraryBreakdown []struct { + Name string `json:"name"` + Minutes int `json:"minutes"` + } `json:"library_breakdown"` + } + require.NoError(t, json.Unmarshal(body, &summary)) + + assert.Equal(t, "7", summary.Range) + require.Len(t, summary.Daily, 7, "zero-filled inclusive calendar") + assert.Equal(t, 155, summary.TotalMinutes, "30+45+60+20 (the 400-day-day session is out of range)") + assert.Equal(t, 65, summary.TotalPages, "12+20+25+8") + assert.Equal(t, 4, summary.SessionCount) + assert.Equal(t, 3, summary.BooksActive, "distinct books with sessions") + assert.Equal(t, 1, summary.BooksFinished, "finished := any push ≥ 99.5%") + assert.InEpsilon(t, float64(1)/float64(3), summary.CompletionRate, 0.001) + + // Streaks: today, −1d, −2d are consecutive; the lone −400d day isn't. + t.Logf("STREAK DUMP: %+v (values %#v)", summary.Streak, summary.Streak) + t.Logf("DAILY DUMP: %+v", summary.Daily) + assert.Equal(t, 3, summary.Streak.Current) + assert.Equal(t, 3, summary.Streak.Longest) + assert.True(t, summary.Streak.ActiveToday) + + // Top books: minutes-ordered — the seeded 60-minute back book tops. + require.NotEmpty(t, summary.TopBooks) + assert.Equal(t, 60, summary.TopBooks[0].Minutes) + for i := 1; i < len(summary.TopBooks); i++ { + assert.GreaterOrEqual(t, summary.TopBooks[i-1].Minutes, summary.TopBooks[i].Minutes, "top books sorted by minutes") + } + + // Library breakdown carries the seeded library. + found := false + for _, lib := range summary.LibraryBreakdown { + if lib.Minutes >= 155 { + found = true + } + } + assert.True(t, found, "library breakdown aggregates the seeded library") + + // Range validation --------------------------------------------------- + badResp, badBody := statsLogin(t, setup, "/api/stats/summary?range=fortnight") + assert.Equal(t, http.StatusBadRequest, badResp.StatusCode) + assert.Contains(t, string(badBody), "invalid range") + + // Auth gate ---------------------------------------------------------- + anonym, err := http.Get(setup.Server.URL + "/api/stats/summary") + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode) + _ = anonym.Body.Close() + + // Widget ------------------------------------------------------------- + resp, body = statsLogin(t, setup, "/api/stats/this-week") + require.Equal(t, http.StatusOK, resp.StatusCode) + var week struct { + WeekMinutes int `json:"week_minutes"` + WeekBooks int `json:"week_books"` + ActiveToday bool `json:"active_today"` + CurrentStreak int `json:"current_streak"` + PrevMinutes int `json:"prev_week_minutes"` + } + require.NoError(t, json.Unmarshal(body, &week)) + assert.Equal(t, 155, week.WeekMinutes) + assert.Equal(t, 3, week.WeekBooks) + assert.True(t, week.ActiveToday) + assert.Equal(t, 3, week.CurrentStreak) + assert.Equal(t, 0, week.PrevMinutes) + + // Per-book card (lifetime, so the 400-day book still totals) ----------- + resp, body = statsLogin(t, setup, "/api/stats/books/"+offRangeBookID) + require.Equal(t, http.StatusOK, resp.StatusCode) + var bookStats struct { + TotalMinutes int `json:"total_minutes"` + SessionCount int `json:"session_count"` + MaxProgress float64 `json:"max_progress"` + } + require.NoError(t, json.Unmarshal(body, &bookStats)) + assert.Equal(t, 120, bookStats.TotalMinutes) + assert.Equal(t, 1, bookStats.SessionCount) + assert.InEpsilon(t, 0.2, bookStats.MaxProgress, 0.001) + + _ = finishedBookID +} diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index f0b41af1..9863fe83 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -507,6 +507,8 @@ func setupTestServer(t *testing.T) *TestServerSetup { filtersHandler := handlers.NewFiltersHandler(queries) dashboardService := services.NewDashboardService(queries) dashboardHandler := handlers.NewDashboardHandler(queries) + statisticsService := services.NewStatisticsService(queries) + statsHandler := handlers.NewStatsHandler(statisticsService) seriesHandler := handlers.NewSeriesHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler.SetProgressService(progressService) @@ -553,6 +555,8 @@ func setupTestServer(t *testing.T) *TestServerSetup { WSHandler: wsHandler, ConflictHandler: conflictHandler, AnalyticsHandler: analyticsHandler, + StatsHandler: statsHandler, + StatisticsService: statisticsService, QueueHandler: queueHandler, SystemSettingsHandler: systemSettingsHandler, ProcessingIssuesHandler: processingIssuesHandler, diff --git a/internal/database/querier.go b/internal/database/querier.go index 730ffc8a..4882247b 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -153,6 +153,10 @@ type Querier interface { DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error + // Device existence probe for capture: the web/app progress paths + // historically stuff the user id into DeviceID (it only fed a broadcast + // label before stats); a capture row must only reference a REAL device. + DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error) // Find content-duplicate groups (same library + SHA-256, more than one row) FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error) GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) @@ -165,6 +169,8 @@ type Querier interface { GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) + // Lifetime session rows for ONE book (the book-detail stats card). + GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) // Get collection GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error) @@ -338,6 +344,8 @@ type Querier interface { GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) GetSystemTimezone(ctx context.Context) (string, error) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) + // Most-read books for a range (minutes), for the top list. + GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error) // Get universal progress for a book GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) // Get unlinked book by ContentId @@ -354,12 +362,27 @@ type Querier interface { // Get user device usage statistics GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) + // Per-library aggregation for the same range. + GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error) GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error) + // Distinct ACTIVE days (any captured session) bucketed in the user's + // timezone — the streak calendar. Scoped to a long look-back so the + // "longest streak" and a current streak longer than the selected range + // are both real. + GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error) // Analytics queries // Get user reading history for analytics GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) + // STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns. + // Every stats query scopes to the requesting user's VISIBLE libraries + // (family lens at the source) and single scan of reading_history. + // Full session rows for a range (summary math runs in Go: tz buckets, + // streaks, finished-book detection). The bucket/range key is the session + // END instant (the push time; NULL falls back to created_at) — the same + // value the capture stamps every write with. + GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) // Smart ("dynamic") collections: rules are evaluated LIVE at render, so diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 53d82863..97f31e24 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2389,6 +2389,28 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser return err } +const DeviceExistsForUser = `-- name: DeviceExistsForUser :one +SELECT EXISTS( + SELECT 1 FROM devices + WHERE id = $1 AND user_id = $2 +) AS has_device +` + +type DeviceExistsForUserParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` +} + +// Device existence probe for capture: the web/app progress paths +// historically stuff the user id into DeviceID (it only fed a broadcast +// label before stats); a capture row must only reference a REAL device. +func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error) { + row := q.db.QueryRow(ctx, DeviceExistsForUser, arg.DeviceID, arg.UserID) + var has_device bool + err := row.Scan(&has_device) + return has_device, err +} + const FindHashConflictGroups = `-- name: FindHashConflictGroups :many SELECT library_id, file_sha256, COUNT(*) AS dup_count FROM media_items @@ -2726,6 +2748,65 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF return items, nil } +const GetBookReadingSessions = `-- name: GetBookReadingSessions :many +SELECT + rh.id, rh.device_id, rh.progress_percentage, + rh.reading_session_start, rh.reading_session_end, + rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at +FROM reading_history rh +WHERE rh.user_id = $1 + AND rh.media_item_id = $2 +ORDER BY rh.created_at ASC +` + +type GetBookReadingSessionsParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +type GetBookReadingSessionsRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"` + ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"` + ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"` + PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"` + TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"` + DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` +} + +// Lifetime session rows for ONE book (the book-detail stats card). +func (q *Queries) GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error) { + rows, err := q.db.Query(ctx, GetBookReadingSessions, arg.UserID, arg.MediaItemID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetBookReadingSessionsRow{} + for rows.Next() { + var i GetBookReadingSessionsRow + if err := rows.Scan( + &i.ID, + &i.DeviceID, + &i.ProgressPercentage, + &i.ReadingSessionStart, + &i.ReadingSessionEnd, + &i.PagesRead, + &i.TimeSpentSeconds, + &i.DeviceMetadata, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetBooksByTag = `-- name: GetBooksByTag :many SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE library_id = $1 AND tags @> ARRAY[$2::text] @@ -7674,6 +7755,85 @@ func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTo return items, nil } +const GetTopBooksByMinutes = `-- name: GetTopBooksByMinutes :many +SELECT + mi.id::uuid AS media_item_id, + mi.title, + mi.author, + mi.library_id, + mi.cover_image_path, + SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds, + SUM(COALESCE(rh.pages_read, 0))::int AS total_pages, + COUNT(*)::int AS session_count, + MAX(rh.created_at) AS last_read +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +WHERE rh.user_id = $1 + AND COALESCE(rh.reading_session_end, rh.created_at) >= $2 + AND COALESCE(rh.reading_session_end, rh.created_at) <= $3 + AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[])) +GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path +ORDER BY total_seconds DESC +LIMIT $5 +` + +type GetTopBooksByMinutesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"` + EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"` + VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` + Limit int32 `db:"limit" json:"limit"` +} + +type GetTopBooksByMinutesRow struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + TotalSeconds int32 `db:"total_seconds" json:"total_seconds"` + TotalPages int32 `db:"total_pages" json:"total_pages"` + SessionCount int32 `db:"session_count" json:"session_count"` + LastRead interface{} `db:"last_read" json:"last_read"` +} + +// Most-read books for a range (minutes), for the top list. +func (q *Queries) GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error) { + rows, err := q.db.Query(ctx, GetTopBooksByMinutes, + arg.UserID, + arg.StartTime, + arg.EndTime, + arg.VisibleLibraryIds, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetTopBooksByMinutesRow{} + for rows.Next() { + var i GetTopBooksByMinutesRow + if err := rows.Scan( + &i.MediaItemID, + &i.Title, + &i.Author, + &i.LibraryID, + &i.CoverImagePath, + &i.TotalSeconds, + &i.TotalPages, + &i.SessionCount, + &i.LastRead, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUniversalProgress = `-- name: GetUniversalProgress :one SELECT rp.id, @@ -8191,6 +8351,74 @@ func (q *Queries) GetUserForLogin(ctx context.Context, email string) (GetUserFor return i, err } +const GetUserLibraryBreakdown = `-- name: GetUserLibraryBreakdown :many +SELECT + l.id::uuid AS library_id, + l.name, + COUNT(*)::int AS session_count, + SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds, + SUM(COALESCE(rh.pages_read, 0))::int AS total_pages, + COUNT(DISTINCT rh.media_item_id)::int AS book_count +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +JOIN libraries l ON l.id = mi.library_id +WHERE rh.user_id = $1 + AND COALESCE(rh.reading_session_end, rh.created_at) >= $2 + AND COALESCE(rh.reading_session_end, rh.created_at) <= $3 + AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[])) +GROUP BY l.id, l.name +ORDER BY total_seconds DESC +` + +type GetUserLibraryBreakdownParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"` + EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"` + VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` +} + +type GetUserLibraryBreakdownRow struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Name string `db:"name" json:"name"` + SessionCount int32 `db:"session_count" json:"session_count"` + TotalSeconds int32 `db:"total_seconds" json:"total_seconds"` + TotalPages int32 `db:"total_pages" json:"total_pages"` + BookCount int32 `db:"book_count" json:"book_count"` +} + +// Per-library aggregation for the same range. +func (q *Queries) GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error) { + rows, err := q.db.Query(ctx, GetUserLibraryBreakdown, + arg.UserID, + arg.StartTime, + arg.EndTime, + arg.VisibleLibraryIds, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUserLibraryBreakdownRow{} + for rows.Next() { + var i GetUserLibraryBreakdownRow + if err := rows.Scan( + &i.LibraryID, + &i.Name, + &i.SessionCount, + &i.TotalSeconds, + &i.TotalPages, + &i.BookCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUserMediaItemsForSync = `-- name: GetUserMediaItemsForSync :many SELECT mi.id, @@ -8358,6 +8586,52 @@ func (q *Queries) GetUserProgressForBooks(ctx context.Context, arg GetUserProgre return items, nil } +const GetUserReadingActiveDays = `-- name: GetUserReadingActiveDays :many +SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE $1::text)::date::text AS day +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +WHERE rh.user_id = $2 + AND COALESCE(rh.reading_session_end, rh.created_at) >= $3 + AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[])) +ORDER BY day ASC +` + +type GetUserReadingActiveDaysParams struct { + Tz string `db:"tz" json:"tz"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"` + VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` +} + +// Distinct ACTIVE days (any captured session) bucketed in the user's +// timezone — the streak calendar. Scoped to a long look-back so the +// "longest streak" and a current streak longer than the selected range +// are both real. +func (q *Queries) GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error) { + rows, err := q.db.Query(ctx, GetUserReadingActiveDays, + arg.Tz, + arg.UserID, + arg.StartTime, + arg.VisibleLibraryIds, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var day string + if err := rows.Scan(&day); err != nil { + return nil, err + } + items = append(items, day) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUserReadingHistory = `-- name: GetUserReadingHistory :many SELECT @@ -8447,6 +8721,93 @@ func (q *Queries) GetUserReadingHistory(ctx context.Context, arg GetUserReadingH return items, nil } +const GetUserReadingSessionsRange = `-- name: GetUserReadingSessionsRange :many + +SELECT + rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage, + rh.reading_session_start, rh.reading_session_end, + rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at, + mi.title, mi.author, mi.library_id, mi.cover_image_path +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +WHERE rh.user_id = $1 + AND COALESCE(rh.reading_session_end, rh.created_at) >= $2 + AND COALESCE(rh.reading_session_end, rh.created_at) <= $3 + AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[])) +ORDER BY rh.created_at ASC +` + +type GetUserReadingSessionsRangeParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"` + EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"` + VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` +} + +type GetUserReadingSessionsRangeRow struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"` + ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"` + ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"` + PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"` + TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"` + DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` +} + +// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns. +// Every stats query scopes to the requesting user's VISIBLE libraries +// (family lens at the source) and single scan of reading_history. +// Full session rows for a range (summary math runs in Go: tz buckets, +// streaks, finished-book detection). The bucket/range key is the session +// END instant (the push time; NULL falls back to created_at) — the same +// value the capture stamps every write with. +func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error) { + rows, err := q.db.Query(ctx, GetUserReadingSessionsRange, + arg.UserID, + arg.StartTime, + arg.EndTime, + arg.VisibleLibraryIds, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUserReadingSessionsRangeRow{} + for rows.Next() { + var i GetUserReadingSessionsRangeRow + if err := rows.Scan( + &i.ID, + &i.MediaItemID, + &i.DeviceID, + &i.ProgressPercentage, + &i.ReadingSessionStart, + &i.ReadingSessionEnd, + &i.PagesRead, + &i.TimeSpentSeconds, + &i.DeviceMetadata, + &i.CreatedAt, + &i.Title, + &i.Author, + &i.LibraryID, + &i.CoverImagePath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description, COALESCE(lv.is_visible, true) as is_visible diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 5ed4ffa6..7b576bf7 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -1467,6 +1467,103 @@ SELECT ORDER BY read_count DESC LIMIT $2; +-- STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns. +-- Every stats query scopes to the requesting user's VISIBLE libraries +-- (family lens at the source) and single scan of reading_history. + +-- Full session rows for a range (summary math runs in Go: tz buckets, +-- streaks, finished-book detection). The bucket/range key is the session +-- END instant (the push time; NULL falls back to created_at) — the same +-- value the capture stamps every write with. +-- name: GetUserReadingSessionsRange :many +SELECT + rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage, + rh.reading_session_start, rh.reading_session_end, + rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at, + mi.title, mi.author, mi.library_id, mi.cover_image_path +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +WHERE rh.user_id = sqlc.arg('user_id') + AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time') + AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time') + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) +ORDER BY rh.created_at ASC; + +-- Per-library aggregation for the same range. +-- name: GetUserLibraryBreakdown :many +SELECT + l.id::uuid AS library_id, + l.name, + COUNT(*)::int AS session_count, + SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds, + SUM(COALESCE(rh.pages_read, 0))::int AS total_pages, + COUNT(DISTINCT rh.media_item_id)::int AS book_count +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +JOIN libraries l ON l.id = mi.library_id +WHERE rh.user_id = sqlc.arg('user_id') + AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time') + AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time') + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) +GROUP BY l.id, l.name +ORDER BY total_seconds DESC; + +-- Most-read books for a range (minutes), for the top list. +-- name: GetTopBooksByMinutes :many +SELECT + mi.id::uuid AS media_item_id, + mi.title, + mi.author, + mi.library_id, + mi.cover_image_path, + SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds, + SUM(COALESCE(rh.pages_read, 0))::int AS total_pages, + COUNT(*)::int AS session_count, + MAX(rh.created_at) AS last_read +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +WHERE rh.user_id = sqlc.arg('user_id') + AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time') + AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time') + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) +GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path +ORDER BY total_seconds DESC +LIMIT sqlc.arg('limit'); + +-- Lifetime session rows for ONE book (the book-detail stats card). +-- name: GetBookReadingSessions :many +SELECT + rh.id, rh.device_id, rh.progress_percentage, + rh.reading_session_start, rh.reading_session_end, + rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at +FROM reading_history rh +WHERE rh.user_id = sqlc.arg('user_id') + AND rh.media_item_id = sqlc.arg('media_item_id') +ORDER BY rh.created_at ASC; + +-- Distinct ACTIVE days (any captured session) bucketed in the user's +-- timezone — the streak calendar. Scoped to a long look-back so the +-- "longest streak" and a current streak longer than the selected range +-- are both real. +-- name: GetUserReadingActiveDays :many +SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE sqlc.arg('tz')::text)::date::text AS day +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +WHERE rh.user_id = sqlc.arg('user_id') + AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time') + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) +ORDER BY day ASC; + +-- Device existence probe for capture: the web/app progress paths +-- historically stuff the user id into DeviceID (it only fed a broadcast +-- label before stats); a capture row must only reference a REAL device. +-- name: DeviceExistsForUser :one +SELECT EXISTS( + SELECT 1 FROM devices + WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id') +) AS has_device; + + -- ============================================ -- DEVICE MANAGEMENT & AUTH -- ============================================ diff --git a/internal/handlers/media_detail.go b/internal/handlers/media_detail.go index 847280d8..f5d72525 100644 --- a/internal/handlers/media_detail.go +++ b/internal/handlers/media_detail.go @@ -16,6 +16,11 @@ type MediaDetail struct { Collections []database.Collections `json:"collections"` ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"` + // The viewer's own reading-history card for this book (nil when the + // user has no sessions here); rendered as the "Your Reading Stats" + // section of the book page. + BookStats *BookStatsData `json:"book_stats,omitempty"` + // Conflict data (if exists) ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"` @@ -27,3 +32,14 @@ type MediaDetail struct { // page's "recently deleted" list with restore/permanent-delete actions. DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"` } + +// BookStatsData is the per-book reading-stats card payload. +type BookStatsData struct { + TotalMinutes int + TotalPages int + SessionCount int + MaxProgress float64 // 0..1 + Finished bool + LastSession string + FirstSession string +} diff --git a/internal/handlers/stats.go b/internal/handlers/stats.go new file mode 100644 index 00000000..94df001f --- /dev/null +++ b/internal/handlers/stats.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "bookhoard/internal/database" + "bookhoard/internal/services" + "net/http" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v5" +) + +// StatsHandler serves the reading-statistics API (/api/stats/*) backed +// by the StatisticsService. The server owns every conversion: clients +// receive user-timezone-rendered strings plus raw minute/page counts. +type StatsHandler struct { + statsService *services.StatisticsService +} + +func NewStatsHandler(statsService *services.StatisticsService) *StatsHandler { + return &StatsHandler{statsService: statsService} +} + +func mustUser(c *echo.Context) (database.Users, bool) { + user, ok := c.Get("user").(database.Users) + return user, ok +} + +// Summary handles GET /api/stats/summary?range=7|30|90|year — the /stats +// page's complete payload: totals, daily arrays, streaks, most-active +// day, top books, per-library breakdown. +func (h *StatsHandler) Summary(c *echo.Context) error { + user, ok := mustUser(c) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") + } + summary, err := h.statsService.UserSummary(c.Request().Context(), + pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + user.Timezone.String, + c.QueryParam("range"), + ) + if err != nil { + if err.Error()[:7] == "invalid" { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, summary) +} + +// ThisWeek handles GET /api/stats/this-week — the dashboard widget's +// data: rolling 7-day totals vs the previous seven days, plus streak. +func (h *StatsHandler) ThisWeek(c *echo.Context) error { + user, ok := mustUser(c) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") + } + week, err := h.statsService.ThisWeek(c.Request().Context(), + pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + user.Timezone.String, + ) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, week) +} + +// BookStats handles GET /api/stats/books/:id — the per-book card on the +// book detail page (lifetime totals + recent sessions). +func (h *StatsHandler) BookStats(c *echo.Context) error { + user, ok := mustUser(c) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") + } + mediaItemID, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid media item id") + } + stats, err := h.statsService.BookStats(c.Request().Context(), + pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + mediaItemID, + user.Timezone.String, + ) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, stats) +} diff --git a/internal/router/analytics.go b/internal/router/analytics.go index 1c341368..76b59490 100644 --- a/internal/router/analytics.go +++ b/internal/router/analytics.go @@ -8,9 +8,18 @@ func registerAnalyticsRoutes(cfg *Config) { protected := e.Group("/api", jwtMiddleware) - // Analytics routes + // Analytics routes (legacy shape; kept for existing API consumers — + // the web UI moved to /stats) analytics := protected.Group("/analytics") analytics.GET("/reading-stats", cfg.AnalyticsHandler.GetReadingStats) analytics.GET("/device-usage", cfg.AnalyticsHandler.GetDeviceUsage) analytics.GET("/popular-books", cfg.AnalyticsHandler.GetPopularBooks) + + // Reading-statistics API (/stats page + dashboard widget + per-book + // card). Same session seed, service-backed aggregates, user-timezone + // bucketing. The legacy /analytics page redirects to /stats. + stats := protected.Group("/stats") + stats.GET("/summary", cfg.StatsHandler.Summary) + stats.GET("/this-week", cfg.StatsHandler.ThisWeek) + stats.GET("/books/:id", cfg.StatsHandler.BookStats) } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 2e7f317a..33ea9811 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -473,8 +473,30 @@ func registerFrontendRoutes(cfg *Config) { sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID)) allSectionsData := handlers.BuildSections(allSections, libraryID) + // “This week” widget: the user's rolling 7-day reading pulse, from + // the same session seed the /stats page uses. Suppressed when the + // service fails (dashboard always renders). + var week *templates.WeekStatsData + if cfg.StatisticsService != nil { + weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(), + pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone) + if weekErr == nil { + week = &templates.WeekStatsData{ + WeekMinutes: weekSvc.WeekMinutes, + WeekPages: weekSvc.WeekPages, + WeekSessions: weekSvc.WeekSessions, + WeekBooks: weekSvc.WeekBooks, + PrevMinutes: weekSvc.PrevMinutes, + ActiveToday: weekSvc.ActiveToday, + CurrentStreak: weekSvc.CurrentStreak, + } + } else { + log.Printf("ThisWeek widget failed: %v", weekErr) + } + } + var buf bytes.Buffer - err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf) + err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, errorMsg).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -948,14 +970,22 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) }) - // Analytics page + // Analytics page → /stats (the rebuilt reading-statistics page; + // nobody bookmarks orphan pages twice). frontendProtected.GET("/analytics", func(c *echo.Context) error { + return c.Redirect(http.StatusMovedPermanently, "/stats") + }) + + // Reading statistics page — per-user by default, with library + // breakdown; the per-book tier lives on the book detail page and the + // dashboard carries the "this week" widget. + frontendProtected.GET("/stats", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } var buf bytes.Buffer - err = templates.Analytics(user).Render(c.Request().Context(), &buf) + err = templates.Stats(user).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -1577,6 +1607,24 @@ func registerFrontendRoutes(cfg *Config) { UserID: pgUserID, }) + // Per-book reading stats (the viewer's own history card) — nil + // keeps the card off the page for untouched books / other users. + var bookStats *handlers.BookStatsData + if cfg.StatisticsService != nil { + if svcStats, serr := cfg.StatisticsService.BookStats(c.Request().Context(), + pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 { + bookStats = &handlers.BookStatsData{ + TotalMinutes: svcStats.TotalMinutes, + TotalPages: svcStats.TotalPages, + SessionCount: svcStats.SessionCount, + MaxProgress: svcStats.MaxProgress, + Finished: svcStats.Finished, + FirstSession: svcStats.FirstSession, + LastSession: svcStats.LastSession, + } + } + } + // Assemble response (no field duplication!) detail := handlers.MediaDetail{ MediaItems: mediaItem, // Embedded - ALL fields available @@ -1584,6 +1632,7 @@ func registerFrontendRoutes(cfg *Config) { Rating: rating, Collections: collections, ReadingProgress: progress, + BookStats: bookStats, ActiveConflict: activeConflict, NotesCount: len(notes), HighlightsCount: len(highlights), diff --git a/internal/router/router.go b/internal/router/router.go index 85b87d29..7ff862a5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -52,6 +52,8 @@ type Config struct { WSHandler *handlers.WSHandler ConflictHandler *handlers.ConflictHandler AnalyticsHandler *handlers.AnalyticsHandler + StatsHandler *handlers.StatsHandler + StatisticsService *services.StatisticsService QueueHandler *handlers.QueueHandler CollectionHandler *handlers.CollectionHandler Worker *services.Worker diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index e9d0dbcb..0fe92100 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -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) } diff --git a/internal/services/statistics_service.go b/internal/services/statistics_service.go new file mode 100644 index 00000000..e82d4c0e --- /dev/null +++ b/internal/services/statistics_service.go @@ -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 "" +} diff --git a/internal/sync/progress.go b/internal/sync/progress.go index e01dff8a..0e3c0fcd 100644 --- a/internal/sync/progress.go +++ b/internal/sync/progress.go @@ -597,9 +597,96 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ ) } + // Reading-history capture: threading stats off the sync stream. Every + // successful progress write yields one reading_history session row — + // the span since the PREVIOUS write instant (bounds are one or the + // other: the row is recorded regardless, time attribution is + // conservative). Client cadence drives the granularity (web/app + // debounced pushes, KOReader page-count pushes); nothing second-level + // is promised and nothing client-side changes. + s.captureReadingSession(ctx, req, existing, hasExisting, result) + return result, nil } +// maxAttributedSessionSeconds caps a derived session: pushes farther +// apart than this carry unattributable idle time (overnight suspend → +// resume, offline queue flushes) and are kept as zero-second markers +// instead of inflating totals. +const maxAttributedSessionSeconds = int32(2 * 60 * 60) + +// captureReadingSession records one reading_history row for this push. +// Never fails the sync: capture errors are logged and swallowed. +func (s *ProgressService) captureReadingSession(ctx context.Context, req SaveProgressRequest, existing database.ReadingProgress, hasExisting bool, saved database.ReadingProgress) { + var seconds int32 + sessionEnd := saved.LastReadAt + sessionStart := sessionEnd // first-contact writes start at the push itself + if sessionEnd.Valid && hasExisting && existing.LastReadAt.Valid { + sessionStart = existing.LastReadAt + if elapsed := int32(sessionEnd.Time.Sub(existing.LastReadAt.Time).Seconds()); elapsed > 0 { + if elapsed <= maxAttributedSessionSeconds { + seconds = elapsed // verified-continuous span + } // else: idle gap — marker row, zero time + } + } + + // Pages read: only when both sides carry a real page index (0 means + // unknown for several clients, e.g. LOC-less KOReader pushes). + var pages pgtype.Int4 + if hasExisting && existing.CurrentPage.Valid && existing.CurrentPage.Int32 > 0 && + saved.CurrentPage.Valid && saved.CurrentPage.Int32 > 0 { + if deltaPages := saved.CurrentPage.Int32 - existing.CurrentPage.Int32; deltaPages > 0 && deltaPages <= 5000 { + pages = pgtype.Int4{Int32: deltaPages, Valid: true} + } + } + + // device_id references real devices (FK). The web/app progress paths + // historically stuff the USER id into DeviceID (it only fed a + // broadcast label before stats existed) — probe before writing, + // otherwise capture a device-less session and keep the label in + // metadata only. + var deviceID pgtype.UUID + if req.DeviceID.Valid { + exists, probeErr := s.db.DeviceExistsForUser(ctx, database.DeviceExistsForUserParams{ + DeviceID: req.DeviceID, + UserID: req.UserID, + }) + if probeErr == nil && exists { + deviceID = req.DeviceID + } + } + metadata := map[string]string{"source": req.Source} + + _, err := s.db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{ + UserID: req.UserID, + MediaItemID: req.MediaItemID, + DeviceID: deviceID, + ProgressPercentage: saved.Percentage, + ReadingSessionStart: sessionStart, + ReadingSessionEnd: sessionEnd, + PagesRead: pages, + TimeSpentSeconds: pgtype.Int4{Int32: seconds, Valid: true}, + DeviceMetadata: mustJSON(metadata), + }) + if err != nil { + log.Printf("reading-history capture failed (non-fatal) for %s: %v", uniqueKeyString(req), err) + } +} + +func uniqueKeyString(req SaveProgressRequest) string { + return uuid.UUID(req.MediaItemID.Bytes).String() +} + +// mustJSON marshals metadata or an empty document on failure — capture +// must never break on a serialization corner. +func mustJSON(v interface{}) []byte { + data, err := json.Marshal(v) + if err != nil { + return []byte(`{}`) + } + return data +} + func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} { data := map[string]interface{}{} if req.Percentage != nil { diff --git a/templates/analytics.templ b/templates/analytics.templ deleted file mode 100644 index c7076696..00000000 --- a/templates/analytics.templ +++ /dev/null @@ -1,95 +0,0 @@ -package templates - -templ Analytics(user User) { - - -
- - -Track your reading habits and device usage
--
--
--
--
-Loading analytics...
-Track your reading habits and device usage
-
-
-
-
Loading analytics...
Time Reading
+ if book.BookStats.TotalMinutes >= 60 { +{ fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60) }
+ } else { +{ fmt.Sprintf("%d min", book.BookStats.TotalMinutes) }
+ } +Sessions
+{ fmt.Sprintf("%d", book.BookStats.SessionCount) }
+Pages Synced
+{ fmt.Sprintf("%d", book.BookStats.TotalPages) }
+Finished
+ if book.BookStats.Finished { +✓
+ } else { +—
+ } +Publisher
") + if book.BookStats != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
Time Reading
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.Publisher.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 291, Col: 69} + if book.BookStats.TotalMinutes >= 60 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 292, Col: 157} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 294, Col: 119} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "Published
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 297, Col: 92} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
ISBN
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
Sessions
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String) + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 303, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 299, Col: 114} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
Language
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
Pages Synced
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.Language.String) + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 309, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 303, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
Finished
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.BookStats.Finished { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "✓
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "—
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "Edition
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
Publisher
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(book.Edition.String) + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(book.Publisher.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 315, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 323, Col: 69} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
Pages
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 321, Col: 68} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
Genre
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.Genre.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 327, Col: 65} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
Series Count
") + if book.DatePublished.Valid { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
Published
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32) + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 333, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 329, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " items
Volume
Vol. ") + if book.Isbn.Valid && book.Isbn.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
ISBN
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32) + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 339, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 335, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -940,17 +918,17 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - if book.Imprint.Valid && book.Imprint.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
Imprint
") + if book.Language.Valid && book.Language.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
Language
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.Imprint.String) + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.Language.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 345, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 341, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -959,17 +937,17 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
Copyright
") + if book.Edition.Valid && book.Edition.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
Edition
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32) + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(book.Edition.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 351, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 347, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -978,17 +956,17 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
Manga Type
") + if book.PageCount.Valid && book.PageCount.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
Pages
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " ")) + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 357, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 353, Col: 68} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -997,17 +975,17 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - if book.ScanInformation.Valid && book.ScanInformation.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
Scan Info
") + if book.Genre.Valid && book.Genre.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
Genre
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String) + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.Genre.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 363, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 359, Col: 65} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1016,36 +994,36 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
Alternate Series
") + if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
Series Count
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 369, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 365, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "
Contributors
") + if book.Volume.Valid && book.Volume.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
Volume
Vol. ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", ")) + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 375, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 371, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1054,34 +1032,36 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
Format
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 380, Col: 67} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
File Size
") + if book.Imprint.Valid && book.Imprint.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
Imprint
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64)) + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(book.Imprint.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 385, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 377, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
Copyright
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 383, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1090,17 +1070,17 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - if user.Role == "admin" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "
Location
") + if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "
Manga Type
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation) + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 391, Col: 113} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 389, Col: 118} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1109,286 +1089,398 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "
Scan Info
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 395, Col: 94} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "
Alternate Series
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 401, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "
Contributors
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", ")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 407, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
Format
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 412, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
File Size
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 417, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
Location
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 423, Col: 113} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "