feat(stats): Library tab — per-library inventory stats + audio-aware Reading tab

The /stats page gains a second tab (Reading | Library) implementing the
Audiobookshelf-style library dashboard (HANDOFF item 9): media_items
INVENTORY aggregates — items, hours/pages, distinct authors, size,
distinct narrators, Top Genres / Top Authors bars, Longest / Largest
items — for one library or the All-libraries aggregate.

- GET /api/stats/library-usage?library_id=<uuid|omit>; family lens
  enforced: a library_id outside the requester's visible set is a 403,
  never a silent pass (AGENTS.md standing rule; 431fec0 history)
- six sqlc queries with the visibility pattern + healthy-item filter
  (archived_at IS NULL AND missing_scan_count = 0); hours_mode lets the
  client swap the Hours/Pages card and the Longest ranking per scope
- Reading tab unit audit (HANDOFF item 3, settled 2026-10-16): minutes
  are the merged currency everywhere honest; only the PAGES unit splits
  — Pages card goes mode-aware (Pages · text / Listening Time in an
  audio-only pool) and the charts row gains Listening per Day (hrs)
  when audio minutes exist in the range; summary daily buckets gain
  audio_minutes split by the item's library type
- tab state in the URL (?tab=library&library_id=…); selector fed by the
  existing /api/libraries/visibility endpoint; main.js?v=4
- integration tests: family-lens 403 pin, hidden-library exclusion
  (differential), seeded text+audio aggregate correctness, auth gate
- docs/developer/api/stats/get_library_usage.md

Known-remaining suite failures are environmental: TestConflictList_Empty
+ TestWebSocketPingPong collide with the concurrently-running app session
(live emulator pushes + scans on the shared dev DB), and
TestAdminCreateUser's timezone expectation drifted from live system
settings — all three fail on the clean tree too.
This commit is contained in:
John O'Keefe committed 2026-10-16 10:20:17 -04:00
1 parent a222ef5c27
commit ce366b1604
13 files changed
+1493 -78

No files matched your search

+180 -6
View File
@@ -5,8 +5,10 @@ import (
"bookhoard/internal/utils"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
@@ -80,10 +82,11 @@ func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
}
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"`
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
Minutes int `json:"minutes"`
AudioMinutes int `json:"audio_minutes"` // listening wall-clock inside Minutes (media-mode split for the /stats side-by-side charts)
Pages int `json:"pages"`
Sessions int `json:"sessions"`
}
type StreakStats struct {
@@ -132,6 +135,7 @@ type UserSummaryStats struct {
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
TotalMinutes int `json:"total_minutes"`
TotalAudioMinutes int `json:"total_audio_minutes"` // listening minutes inside TotalMinutes (mode-aware wording surface)
TotalPages int `json:"total_pages"`
SessionCount int `json:"session_count"`
BooksActive int `json:"books_with_activity"`
@@ -198,6 +202,11 @@ func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pg
return ids, nil
}
// ErrLibraryNotVisible is returned by LibraryUsage when the caller asked
// for a scoped library outside the requester's visible set — the handler
// maps it to 403 (never silently pass; the AGENTS.md family-lens rule).
var ErrLibraryNotVisible = errors.New("library not in the user's visible set")
// visibleOrNil maps an empty visible set to a NULL filter argument.
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
if len(ids) == 0 {
@@ -270,11 +279,23 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
continue // defensive: outside the inclusive window
}
// Media mode (the side-by-side wording split): minutes are the
// merged currency everywhere; PAGES only exist for text. The
// joined item's library type is the discriminator (denormalized
// column, trigger-maintained) — 'audiobooks' = audio wall-clock,
// everything else = text.
isAudio := strings.EqualFold(strings.TrimSpace(sess.LibraryTypeName.String), "audiobooks")
if sess.TimeSpentSeconds.Valid {
seconds := int64(sess.TimeSpentSeconds.Int32)
totalSessionSeconds += seconds
summary.TotalMinutes += int(seconds / 60)
entry.Minutes += int(seconds / 60)
minutes := int(seconds / 60)
summary.TotalMinutes += minutes
entry.Minutes += minutes
if isAudio {
summary.TotalAudioMinutes += minutes
entry.AudioMinutes += minutes
}
}
if sess.PagesRead.Valid {
summary.TotalPages += int(sess.PagesRead.Int32)
@@ -526,6 +547,159 @@ func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz
return out, nil
}
// LibraryUsage types — the /stats Library tab (Audiobookshelf-style
// GENERAL book stats). INVENTORY facts from media_items: what is in the
// scoped pool, not what was read. Unlike the reading aggregates these are
// not calendar-bound; the reading tab keeps its own range selector.
type NameCountStats struct {
Name string `json:"name"`
Count int `json:"count"`
}
type UsageItemStats struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImage string `json:"cover_image_path"`
Seconds float64 `json:"seconds"` // audio duration (0 unless hours_mode)
PageCount int `json:"pages"` // text pages (0 unless !hours_mode)
SizeBytes int64 `json:"size"` // always set, both modes
}
// LibraryUsageStats is the whole tab payload for one scope: "" LibraryID
// = the All-libraries aggregate across the requester's visible set.
// HoursMode says which unit the LONGEST panel (and the runtime card
// swap) render: true = audio hours (audio present in scope), false =
// text pages. TotalSeconds/TotalPages both always carry their sums so
// the client never has to infer.
type LibraryUsageStats struct {
LibraryID string `json:"library_id"`
Items int `json:"items"`
Authors int `json:"authors"`
Narrators int `json:"narrators"`
TotalSizeBytes int64 `json:"total_size_bytes"`
TotalSeconds int64 `json:"total_seconds"`
TotalPages int `json:"total_pages"`
HoursMode bool `json:"hours_mode"`
Genres []NameCountStats `json:"genres"`
AuthorsTop []NameCountStats `json:"top_authors"`
LongestItems []UsageItemStats `json:"longest_items"`
LargestItems []UsageItemStats `json:"largest_items"`
}
// LibraryUsage builds the /stats Library tab payload for one scope.
// libraryID scopes to one library (invalid = all visible). An explicit
// library OUTSIDE the requester's visible set returns ErrLibraryNotVisible
// (handler → 403; never silently pass — the family-lens standing rule).
func (s *StatisticsService) LibraryUsage(ctx context.Context, userID pgtype.UUID, libraryID pgtype.UUID) (LibraryUsageStats, error) {
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
if err != nil {
return LibraryUsageStats{}, err
}
visible := visibleOrNil(visibleIDs)
if libraryID.Valid {
found := false
for _, id := range visibleIDs {
if id == libraryID {
found = true
break
}
}
if !found {
return LibraryUsageStats{}, ErrLibraryNotVisible
}
}
params := database.GetLibraryUsageCardsParams{
Column1: libraryID,
Column2: visible,
}
cards, err := s.db.GetLibraryUsageCards(ctx, params)
if err != nil {
return LibraryUsageStats{}, fmt.Errorf("failed to load library cards: %w", err)
}
out := LibraryUsageStats{
LibraryID: "",
Items: int(cards.Items),
Authors: int(cards.Authors),
Narrators: int(cards.Narrators),
TotalSizeBytes: cards.TotalSizeBytes,
TotalSeconds: cards.TotalSeconds,
TotalPages: int(cards.TotalPages),
HoursMode: cards.TotalSeconds > 0,
Genres: []NameCountStats{},
AuthorsTop: []NameCountStats{},
LongestItems: []UsageItemStats{},
LargestItems: []UsageItemStats{},
}
if libraryID.Valid {
out.LibraryID = uuid.UUID(libraryID.Bytes).String()
}
genres, err := s.db.GetLibraryUsageGenres(ctx, database.GetLibraryUsageGenresParams(params))
if err != nil {
return LibraryUsageStats{}, fmt.Errorf("failed to load library genres: %w", err)
}
for _, row := range genres {
out.Genres = append(out.Genres, NameCountStats{Name: row.Name, Count: int(row.RowCount)})
}
authors, err := s.db.GetLibraryUsageAuthors(ctx, database.GetLibraryUsageAuthorsParams(params))
if err != nil {
return LibraryUsageStats{}, fmt.Errorf("failed to load library authors: %w", err)
}
for _, row := range authors {
out.AuthorsTop = append(out.AuthorsTop, NameCountStats{Name: row.Name, Count: int(row.RowCount)})
}
// The longest panel ranks by hours when audio data exists in scope,
// else by pages (text libraries; the "Longest Items" equivalent that
// actually means something there).
longestRows, err := s.db.GetLibraryUsageLongestItems(ctx, database.GetLibraryUsageLongestItemsParams{
Column1: libraryID,
Column2: visible,
Column3: out.HoursMode,
})
if err != nil {
return LibraryUsageStats{}, fmt.Errorf("failed to load longest items: %w", err)
}
for _, row := range longestRows {
entry := UsageItemStats{
Title: row.Title,
Author: row.Author,
Seconds: float64(row.TotalSeconds),
PageCount: int(row.PageCount),
SizeBytes: row.FileSizeBytes,
}
if row.MediaItemID.Valid {
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
entry.CoverImage = utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath)
}
out.LongestItems = append(out.LongestItems, entry)
}
largestRows, err := s.db.GetLibraryUsageLargestItems(ctx, database.GetLibraryUsageLargestItemsParams(params))
if err != nil {
return LibraryUsageStats{}, fmt.Errorf("failed to load largest items: %w", err)
}
for _, row := range largestRows {
entry := UsageItemStats{
Title: row.Title,
Author: row.Author,
SizeBytes: row.FileSizeBytes,
}
if row.MediaItemID.Valid {
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
entry.CoverImage = utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath)
}
out.LargestItems = append(out.LargestItems, entry)
}
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) {