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

+247
View File
@@ -517,3 +517,250 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode) assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
} }
// TestLibraryUsageStats: the /stats Library tab (GET
// /api/stats/library-usage) — media_items INVENTORY aggregates scoped per
// request. Read-only over seeded rows (no reading_history), so the live
// dev-reader warnings do not apply. Seeds both a text and an audiobook
// library to pin the hours-vs-pages mode selection and every panel.
func TestLibraryUsageStats(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
audioID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Audio %d", time.Now().UnixNano()), "audiobooks"))
seed := func(lib uuid.UUID, title, author, genre string, pages int32, size int64, narrators []string, durationSeconds int) database.MediaItems {
if narrators == nil {
narrators = []string{} // NOT NULL DEFAULT '{}' — explicit NULL would 23502
}
audioMeta := []byte(nil)
if durationSeconds > 0 {
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
}
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
Author: pgtype.Text{String: author, Valid: true},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: size, Valid: true},
MimeType: pgtype.Text{String: "application/octet-stream", Valid: true},
PageCount: pgInt4(pages),
Narrators: narrators,
NarratorsSearch: narrators,
AudioMetadata: audioMeta,
})
require.NoError(t, err, "seed usage item")
return media
}
// Text library: 3 items, 2 distinct authors, genres Fantasy×2 +
// Computers×1, 1000 total pages, longest = the 500-page book.
seed(ebooksID, "Usage Dune", "Ann Author", "Fantasy", 300, 1_000_000, nil, 0)
seed(ebooksID, "Usage Botany", "Ann Author", "Fantasy", 200, 2_000_000, nil, 0)
seed(ebooksID, "Usage Computery", "Bob Author", "Computers", 500, 500_000, nil, 0)
// Audio library: 2 items, 1 author, 2 distinct narrators, real
// durations (36h + 5h), the 36h one also the largest file.
seed(audioID, "Usage LongListen", "Cal Author", "", 0, 1_400_000_000,
[]string{"Ann Narrator", "Bob Narrator"}, 129_600)
seed(audioID, "Usage ShortListen", "Cal Author", "", 0, 900_000_000,
[]string{"Ann Narrator"}, 18_000)
fetch := func(query string) (int, []byte) {
url := setup.Server.URL + "/api/stats/library-usage" + query
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, body
}
type UsageItemT struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
Seconds float64 `json:"seconds"`
Pages int `json:"pages"`
Size int64 `json:"size"`
}
type NameCountT struct {
Name string `json:"name"`
Count int `json:"count"`
}
var usage 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 []NameCountT `json:"genres"`
AuthorsTop []NameCountT `json:"top_authors"`
LongestItems []UsageItemT `json:"longest_items"`
LargestItems []UsageItemT `json:"largest_items"`
}
decode := func(body []byte) {
require.NoError(t, json.Unmarshal(body, &usage))
}
// Text library scope: pages mode.
code, body := fetch("?library_id=" + ebooksID.String())
require.Equal(t, http.StatusOK, code)
decode(body)
assert.Equal(t, ebooksID.String(), usage.LibraryID)
assert.Equal(t, 3, usage.Items)
assert.Equal(t, 2, usage.Authors)
assert.Equal(t, 0, usage.Narrators)
assert.Equal(t, int64(3_500_000), usage.TotalSizeBytes)
assert.Equal(t, int64(0), usage.TotalSeconds)
assert.Equal(t, 1000, usage.TotalPages)
assert.False(t, usage.HoursMode, "no audio in scope → pages mode")
require.Len(t, usage.Genres, 2)
assert.Equal(t, "Fantasy", usage.Genres[0].Name)
assert.Equal(t, 2, usage.Genres[0].Count)
assert.Equal(t, "Computers", usage.Genres[1].Name)
require.Len(t, usage.AuthorsTop, 2)
assert.Equal(t, "Ann Author", usage.AuthorsTop[0].Name)
assert.Equal(t, 2, usage.AuthorsTop[0].Count)
require.Len(t, usage.LongestItems, 3)
assert.Equal(t, "Usage Computery", usage.LongestItems[0].Title)
assert.Equal(t, 500, usage.LongestItems[0].Pages)
require.Len(t, usage.LargestItems, 3)
assert.Equal(t, int64(2_000_000), usage.LargestItems[0].Size, "largest = file_size DESC")
// Audio library scope: hours mode.
code, body = fetch("?library_id=" + audioID.String())
require.Equal(t, http.StatusOK, code)
decode(body)
assert.Equal(t, 2, usage.Items)
assert.Equal(t, 1, usage.Authors)
assert.Equal(t, 2, usage.Narrators, "Ann + Bob across the two audiobooks")
assert.Equal(t, int64(147_600), usage.TotalSeconds, "36h + 5h")
assert.True(t, usage.HoursMode, "audio in scope → hours mode")
require.Len(t, usage.LongestItems, 2)
assert.Equal(t, "Usage LongListen", usage.LongestItems[0].Title)
assert.InEpsilon(t, 129_600, usage.LongestItems[0].Seconds, 0.001)
require.Len(t, usage.LargestItems, 2)
assert.Equal(t, int64(1_400_000_000), usage.LargestItems[0].Size)
// All libraries (no param): the aggregate across the visible set.
// The shared dev DB carries REAL libraries every user sees by default
// (Manga/Comics/Ebooks + whatever the live reader has), so totals are
// relative floors — but inclusion + ranking are exact.
code, body = fetch("")
require.Equal(t, http.StatusOK, code)
decode(body)
assert.Equal(t, "", usage.LibraryID)
assert.GreaterOrEqual(t, usage.Items, 5, "both test pools are in the aggregate")
assert.GreaterOrEqual(t, usage.Authors, 3)
assert.GreaterOrEqual(t, usage.Narrators, 2)
assert.GreaterOrEqual(t, usage.TotalSeconds, int64(147_600), "audio sums include the seeded audiobooks")
assert.GreaterOrEqual(t, usage.TotalPages, 1000)
assert.True(t, usage.HoursMode, "audio exists in the aggregate → longest ranks by hours")
require.NotEmpty(t, usage.LongestItems)
assert.GreaterOrEqual(t, usage.LongestItems[0].Seconds, float64(129_600),
"the seeded 36h item ranks at-or-above the top of the longest list")
// Malformed scope is a clean 400.
code, _ = fetch("?library_id=nope")
assert.Equal(t, http.StatusBadRequest, code)
// Auth gate.
anonym, err := http.Get(setup.Server.URL + "/api/stats/library-usage")
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode)
_ = anonym.Body.Close()
}
// TestLibraryUsageStats_Visibility pins the family-lens contract on the
// Library tab: a library hidden from the requester contributes NOTHING to
// the All-libraries aggregate, and naming it explicitly is a 403 — never
// a silent pass (AGENTS.md standing rule; history: 431fec0).
func TestLibraryUsageStats_Visibility(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
hiddenID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
seed := func(lib uuid.UUID, title string, size int64) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: size, Valid: true},
MimeType: pgtype.Text{String: "application/octet-stream", Valid: true},
Narrators: []string{},
NarratorsSearch: []string{},
})
require.NoError(t, err)
}
seed(visibleID, "Usage Visible One", 1_000_000)
seed(visibleID, "Usage Visible Two", 2_000_000)
seed(hiddenID, "Usage Hidden One", 4_000_000)
setVisibility := func(visible bool) int {
payload, _ := json.Marshal(map[string]interface{}{
"library_id": hiddenID.String(),
"is_visible": visible,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
fetch := func(query string) (int, []byte) {
url := setup.Server.URL + "/api/stats/library-usage" + query
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, body
}
// Baseline with the library visible: the aggregate includes its item.
code, body := fetch("")
require.Equal(t, http.StatusOK, code)
var before struct {
Items int `json:"items"`
}
require.NoError(t, json.Unmarshal(body, &before))
// Hide, then re-measure: a differential of exactly one item proves
// the hidden pool stays out of the aggregate (exact, and immune to
// whatever else lives in the shared dev libraries).
require.Equal(t, http.StatusOK, setVisibility(false))
defer func() { require.Equal(t, http.StatusOK, setVisibility(true)) }()
// Explicit hidden library → 403, the REQUIRED pin.
code, _ = fetch("?library_id=" + hiddenID.String())
assert.Equal(t, http.StatusForbidden, code, "hidden library scope must 403")
// The All-libraries aggregate excludes the hidden pool.
var after struct {
Items int `json:"items"`
}
code, body = fetch("")
require.Equal(t, http.StatusOK, code)
require.NoError(t, json.Unmarshal(body, &after))
assert.Equal(t, before.Items-1, after.Items, "hiding the library removes exactly its item from the aggregate")
}
@@ -0,0 +1,103 @@
# Get Library Usage Statistics
The `/stats` page's **Library tab** payload: media_items **inventory**
aggregates (what is IN the library), not reading behavior. Mirrors the
Audiobookshelf library dashboard's information architecture.
**Endpoint**: `GET /api/stats/library-usage`
**Auth**: Required (any authenticated user)
## Query Parameters
| Parameter | Type | Required | Description |
| ---------- | ------------- | -------- | --------------------------------------------------------------------------- |
| library_id | string (UUID) | No | Scope to one library. **Omit** to aggregate across ALL of the requesting user's visible libraries. |
## Family-lens rule
Every response is scoped to the requester's **visible-library set**
(`library_visibility`, default-ALLOW). An explicit `library_id` OUTSIDE
that set is a **403** — never a silent pass. Omitted `library_id`
aggregates only visible libraries.
## Example Request
```http
GET /api/stats/library-usage?library_id=8c8d0cee-1cca-4def-9a37-edad3a4d438b
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json
{
"library_id": "8c8d0cee-1cca-4def-9a37-edad3a4d438b",
"items": 743,
"authors": 446,
"narrators": 41,
"total_size_bytes": 309237452800,
"total_seconds": 291240,
"total_pages": 0,
"hours_mode": true,
"genres": [
{ "name": "Fantasy", "count": 142 }
],
"top_authors": [
{ "name": "Terry Pratchett", "count": 42 }
],
"longest_items": [
{
"media_item_id": "uuid",
"title": "Gardens of the Moon",
"author": "Steven Erikson",
"cover_image_path": "/uploads/8c8d0cee/cover.jpg",
"seconds": 93600,
"pages": 0,
"size": 1400000000
}
],
"largest_items": [
{
"media_item_id": "uuid",
"title": "Gardens of the Moon",
"author": "Steven Erikson",
"cover_image_path": "/uploads/8c8d0cee/cover.jpg",
"seconds": 93600,
"pages": 0,
"size": 1400000000
}
]
}
```
### Field semantics
| Field | Meaning |
| -------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `library_id` | `""` when the scope is the All-libraries aggregate. |
| `items` | Healthy media items (archived / missing-scan rows never count). |
| `authors` | DISTINCT non-empty `author` values. |
| `narrators` | DISTINCT values unnested from the `narrators TEXT[]` column across the scope. |
| `total_size_bytes` | SUM of `file_size`. |
| `total_seconds` | SUM of `audio_metadata.duration_seconds` — real audio hours; 0 for text libraries. |
| `total_pages` | SUM of `page_count` — meaningful for text libraries; 0 for audiobooks. |
| `hours_mode` | `true` when audio-duration data exists in scope: the client renders the time card in HOURS and ranks Longest Items by `seconds`. `false`: the time card renders in PAGES and Longest Items ranks by `pages`. The client decides — the payload always carries both sums. |
| `genres` | Top 5 non-empty `genre` by item count (ties break alphabetically). |
| `top_authors` | Top 10 non-empty `author` by item count (ties break alphabetically). |
| `longest_items` | Top 5 by `seconds` (hours mode) or `pages` (pages mode). |
| `largest_items` | Top 5 by `file_size` — works for every library type. |
### Absent data
`Audio Tracks` (Audiobookshelf's per-file count) is deliberately absent:
Bookhoard audiobooks are one file per book until multi-file support
lands, so a file count would just equal `items`. See HANDOFF item 9.
## Error Responses
| Code | Description |
| ---- | ------------------------------------------------------ |
| 400 | `library_id` is not a valid UUID |
| 401 | Invalid or expired token |
| 403 | Library not in the user's visible set |
| 500 | Aggregation failure |
+23
View File
@@ -80,6 +80,9 @@ type Querier interface {
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error) CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error) CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
// Media Items queries // Media Items queries
// narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
// pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
// column default and 23502s). The API create handler normalizes nil.
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
// MEDIA ITEM FORMATS QUERIES // MEDIA ITEM FORMATS QUERIES
// Create media item format // Create media item format
@@ -88,6 +91,8 @@ type Querier interface {
// (library_id, file_path) this inserts NOTHING and returns no row // (library_id, file_path) this inserts NOTHING and returns no row
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row // (pgx.ErrNoRows) — the handler then answers 200 with the existing row
// and a duplicate flag instead of pretending it created something. // and a duplicate flag instead of pretending it created something.
// narrators NOT NULL: callers pass empty slices, never nil (see
// CreateMediaItem note).
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
// Media Notes queries // Media Notes queries
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error) CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
@@ -250,6 +255,24 @@ type Querier interface {
GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error) GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error)
// Library Types queries // Library Types queries
GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error) GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error)
GetLibraryUsageAuthors(ctx context.Context, arg GetLibraryUsageAuthorsParams) ([]GetLibraryUsageAuthorsRow, error)
// ============================================================================
// LIBRARY USAGE STATS (the /stats Library tab; the StatisticsService
// LibraryUsage aggregate). media_items INVENTORY aggregates — what is in
// the library, not what was read. Every query carries BOTH filters:
// $1 = single-library scope (NULL = all visible libraries — "All")
// $2 = the requester's visible-library id set (NULL = no filter; the
// default-ALLOW family lens). A non-visible $1 is REJECTED at the
// service/handler layer with 403 — the SQL filter is defense only.
// + the healthy-item filter, matched to GetVisibleLibraryMediaCounts:
// archived_at IS NULL AND missing_scan_count = 0 (dead rows lie).
// ============================================================================
GetLibraryUsageCards(ctx context.Context, arg GetLibraryUsageCardsParams) (GetLibraryUsageCardsRow, error)
GetLibraryUsageGenres(ctx context.Context, arg GetLibraryUsageGenresParams) ([]GetLibraryUsageGenresRow, error)
GetLibraryUsageLargestItems(ctx context.Context, arg GetLibraryUsageLargestItemsParams) ([]GetLibraryUsageLargestItemsRow, error)
// The Longest Items panel, one query both modes: $3 = true ranks by
// AUDIO duration (audio present in scope), false by page_count (text).
GetLibraryUsageLongestItems(ctx context.Context, arg GetLibraryUsageLongestItemsParams) ([]GetLibraryUsageLongestItemsRow, error)
GetLibraryVisibility(ctx context.Context, arg GetLibraryVisibilityParams) (LibraryVisibility, error) GetLibraryVisibility(ctx context.Context, arg GetLibraryVisibilityParams) (LibraryVisibility, error)
// ============================================================================ // ============================================================================
// LIBRARY WITH TYPE INFO QUERIES // LIBRARY WITH TYPE INFO QUERIES
+294 -1
View File
@@ -1037,6 +1037,9 @@ type CreateMediaItemParams struct {
} }
// Media Items queries // Media Items queries
// narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
// pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
// column default and 23502s). The API create handler normalizes nil.
func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) { func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) {
row := q.db.QueryRow(ctx, CreateMediaItem, row := q.db.QueryRow(ctx, CreateMediaItem,
arg.LibraryID, arg.LibraryID,
@@ -1273,6 +1276,8 @@ type CreateMediaItemIfNewParams struct {
// (library_id, file_path) this inserts NOTHING and returns no row // (library_id, file_path) this inserts NOTHING and returns no row
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row // (pgx.ErrNoRows) — the handler then answers 200 with the existing row
// and a duplicate flag instead of pretending it created something. // and a duplicate flag instead of pretending it created something.
// narrators NOT NULL: callers pass empty slices, never nil (see
// CreateMediaItem note).
func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) { func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) {
row := q.db.QueryRow(ctx, CreateMediaItemIfNew, row := q.db.QueryRow(ctx, CreateMediaItemIfNew,
arg.LibraryID, arg.LibraryID,
@@ -4996,6 +5001,291 @@ func (q *Queries) GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error) {
return items, nil return items, nil
} }
const GetLibraryUsageAuthors = `-- name: GetLibraryUsageAuthors :many
SELECT COALESCE(mi.author, '') AS name, COUNT(*)::int AS row_count
FROM media_items mi
WHERE mi.author IS NOT NULL AND mi.author <> ''
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
GROUP BY mi.author
ORDER BY row_count DESC, name ASC
LIMIT 10
`
type GetLibraryUsageAuthorsParams struct {
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
}
type GetLibraryUsageAuthorsRow struct {
Name string `db:"name" json:"name"`
RowCount int32 `db:"row_count" json:"row_count"`
}
func (q *Queries) GetLibraryUsageAuthors(ctx context.Context, arg GetLibraryUsageAuthorsParams) ([]GetLibraryUsageAuthorsRow, error) {
rows, err := q.db.Query(ctx, GetLibraryUsageAuthors, arg.Column1, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetLibraryUsageAuthorsRow{}
for rows.Next() {
var i GetLibraryUsageAuthorsRow
if err := rows.Scan(&i.Name, &i.RowCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetLibraryUsageCards = `-- name: GetLibraryUsageCards :one
WITH usage_scope AS (
SELECT mi.author, mi.file_size, mi.page_count, mi.narrators, mi.audio_metadata
FROM media_items mi
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
)
SELECT
(SELECT COUNT(*) FROM usage_scope)::int AS items,
(SELECT COUNT(DISTINCT author) FROM usage_scope
WHERE author IS NOT NULL AND author <> '')::int AS authors,
(SELECT COUNT(DISTINCT n) FROM usage_scope s, unnest(s.narrators) AS n
WHERE n <> '')::int AS narrators,
(SELECT COALESCE(SUM(file_size), 0) FROM usage_scope)::bigint AS total_size_bytes,
(SELECT COALESCE(SUM(page_count), 0) FROM usage_scope)::bigint AS total_pages,
(SELECT COALESCE(
SUM((s.audio_metadata->>'duration_seconds')::float8), 0
)::bigint FROM usage_scope s
WHERE s.audio_metadata IS NOT NULL
AND s.audio_metadata ? 'duration_seconds') AS total_seconds
`
type GetLibraryUsageCardsParams struct {
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
}
type GetLibraryUsageCardsRow struct {
Items int32 `db:"items" json:"items"`
Authors int32 `db:"authors" json:"authors"`
Narrators int32 `db:"narrators" json:"narrators"`
TotalSizeBytes int64 `db:"total_size_bytes" json:"total_size_bytes"`
TotalPages int64 `db:"total_pages" json:"total_pages"`
TotalSeconds int64 `db:"total_seconds" json:"total_seconds"`
}
// ============================================================================
// LIBRARY USAGE STATS (the /stats Library tab; the StatisticsService
// LibraryUsage aggregate). media_items INVENTORY aggregates — what is in
// the library, not what was read. Every query carries BOTH filters:
//
// $1 = single-library scope (NULL = all visible libraries — "All")
// $2 = the requester's visible-library id set (NULL = no filter; the
// default-ALLOW family lens). A non-visible $1 is REJECTED at the
// service/handler layer with 403 — the SQL filter is defense only.
// + the healthy-item filter, matched to GetVisibleLibraryMediaCounts:
// archived_at IS NULL AND missing_scan_count = 0 (dead rows lie).
//
// ============================================================================
func (q *Queries) GetLibraryUsageCards(ctx context.Context, arg GetLibraryUsageCardsParams) (GetLibraryUsageCardsRow, error) {
row := q.db.QueryRow(ctx, GetLibraryUsageCards, arg.Column1, arg.Column2)
var i GetLibraryUsageCardsRow
err := row.Scan(
&i.Items,
&i.Authors,
&i.Narrators,
&i.TotalSizeBytes,
&i.TotalPages,
&i.TotalSeconds,
)
return i, err
}
const GetLibraryUsageGenres = `-- name: GetLibraryUsageGenres :many
SELECT COALESCE(mi.genre, '') AS name, COUNT(*)::int AS row_count
FROM media_items mi
WHERE mi.genre IS NOT NULL AND mi.genre <> ''
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
GROUP BY mi.genre
ORDER BY row_count DESC, name ASC
LIMIT 5
`
type GetLibraryUsageGenresParams struct {
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
}
type GetLibraryUsageGenresRow struct {
Name string `db:"name" json:"name"`
RowCount int32 `db:"row_count" json:"row_count"`
}
func (q *Queries) GetLibraryUsageGenres(ctx context.Context, arg GetLibraryUsageGenresParams) ([]GetLibraryUsageGenresRow, error) {
rows, err := q.db.Query(ctx, GetLibraryUsageGenres, arg.Column1, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetLibraryUsageGenresRow{}
for rows.Next() {
var i GetLibraryUsageGenresRow
if err := rows.Scan(&i.Name, &i.RowCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetLibraryUsageLargestItems = `-- name: GetLibraryUsageLargestItems :many
SELECT
mi.id::uuid AS media_item_id,
mi.title,
COALESCE(mi.author, '') AS author,
mi.library_id,
mi.cover_image_path,
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes
FROM media_items mi
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND COALESCE(mi.file_size, 0) > 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
ORDER BY mi.file_size DESC, mi.title ASC
LIMIT 5
`
type GetLibraryUsageLargestItemsParams struct {
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
}
type GetLibraryUsageLargestItemsRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Title string `db:"title" json:"title"`
Author string `db:"author" json:"author"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
FileSizeBytes int64 `db:"file_size_bytes" json:"file_size_bytes"`
}
func (q *Queries) GetLibraryUsageLargestItems(ctx context.Context, arg GetLibraryUsageLargestItemsParams) ([]GetLibraryUsageLargestItemsRow, error) {
rows, err := q.db.Query(ctx, GetLibraryUsageLargestItems, arg.Column1, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetLibraryUsageLargestItemsRow{}
for rows.Next() {
var i GetLibraryUsageLargestItemsRow
if err := rows.Scan(
&i.MediaItemID,
&i.Title,
&i.Author,
&i.LibraryID,
&i.CoverImagePath,
&i.FileSizeBytes,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetLibraryUsageLongestItems = `-- name: GetLibraryUsageLongestItems :many
SELECT
mi.id::uuid AS media_item_id,
mi.title,
COALESCE(mi.author, '') AS author,
mi.library_id,
mi.cover_image_path,
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes,
COALESCE(mi.page_count, 0)::int AS page_count,
COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)::bigint AS total_seconds
FROM media_items mi
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
AND (CASE WHEN $3::bool
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0) > 0
ELSE COALESCE(mi.page_count, 0) > 0
END)
ORDER BY (CASE WHEN $3::bool
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)
ELSE COALESCE(mi.page_count, 0)::float8
END) DESC, mi.title ASC
LIMIT 5
`
type GetLibraryUsageLongestItemsParams struct {
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
Column3 bool `db:"column_3" json:"column_3"`
}
type GetLibraryUsageLongestItemsRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Title string `db:"title" json:"title"`
Author string `db:"author" json:"author"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
FileSizeBytes int64 `db:"file_size_bytes" json:"file_size_bytes"`
PageCount int32 `db:"page_count" json:"page_count"`
TotalSeconds int64 `db:"total_seconds" json:"total_seconds"`
}
// The Longest Items panel, one query both modes: $3 = true ranks by
// AUDIO duration (audio present in scope), false by page_count (text).
func (q *Queries) GetLibraryUsageLongestItems(ctx context.Context, arg GetLibraryUsageLongestItemsParams) ([]GetLibraryUsageLongestItemsRow, error) {
rows, err := q.db.Query(ctx, GetLibraryUsageLongestItems, arg.Column1, arg.Column2, arg.Column3)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetLibraryUsageLongestItemsRow{}
for rows.Next() {
var i GetLibraryUsageLongestItemsRow
if err := rows.Scan(
&i.MediaItemID,
&i.Title,
&i.Author,
&i.LibraryID,
&i.CoverImagePath,
&i.FileSizeBytes,
&i.PageCount,
&i.TotalSeconds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetLibraryVisibility = `-- name: GetLibraryVisibility :one const GetLibraryVisibility = `-- name: GetLibraryVisibility :one
SELECT id, user_id, library_id, is_visible, created_at, updated_at FROM library_visibility WHERE user_id = $1 AND library_id = $2 SELECT id, user_id, library_id, is_visible, created_at, updated_at FROM library_visibility WHERE user_id = $1 AND library_id = $2
` `
@@ -8987,7 +9277,8 @@ SELECT
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage, rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
rh.reading_session_start, rh.reading_session_end, rh.reading_session_start, rh.reading_session_end,
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at, rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
mi.title, mi.author, mi.library_id, mi.cover_image_path mi.title, mi.author, mi.library_id, mi.cover_image_path,
mi.library_type_name
FROM reading_history rh FROM reading_history rh
JOIN media_items mi ON mi.id = rh.media_item_id JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = $1 WHERE rh.user_id = $1
@@ -9021,6 +9312,7 @@ type GetUserReadingSessionsRangeRow struct {
Author pgtype.Text `db:"author" json:"author"` Author pgtype.Text `db:"author" json:"author"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
} }
// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns. // STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
@@ -9060,6 +9352,7 @@ func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserRe
&i.Author, &i.Author,
&i.LibraryID, &i.LibraryID,
&i.CoverImagePath, &i.CoverImagePath,
&i.LibraryTypeName,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+110 -1
View File
@@ -149,6 +149,9 @@ GROUP BY l.id;
-- Media Items queries -- Media Items queries
-- name: CreateMediaItem :one -- name: CreateMediaItem :one
-- narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
-- pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
-- column default and 23502s). The API create handler normalizes nil.
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, 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, library_type_name, audio_metadata, narrators, narrators_search, abridged) INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, 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, library_type_name, audio_metadata, narrators, narrators_search, abridged)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW() ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
@@ -159,6 +162,8 @@ RETURNING *;
-- (library_id, file_path) this inserts NOTHING and returns no row -- (library_id, file_path) this inserts NOTHING and returns no row
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row -- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
-- and a duplicate flag instead of pretending it created something. -- and a duplicate flag instead of pretending it created something.
-- narrators NOT NULL: callers pass empty slices, never nil (see
-- CreateMediaItem note).
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, 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, library_type_name, audio_metadata, narrators, narrators_search, abridged) INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, 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, library_type_name, audio_metadata, narrators, narrators_search, abridged)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
ON CONFLICT (library_id, file_path) DO NOTHING ON CONFLICT (library_id, file_path) DO NOTHING
@@ -1543,7 +1548,8 @@ SELECT
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage, rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
rh.reading_session_start, rh.reading_session_end, rh.reading_session_start, rh.reading_session_end,
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at, rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
mi.title, mi.author, mi.library_id, mi.cover_image_path mi.title, mi.author, mi.library_id, mi.cover_image_path,
mi.library_type_name
FROM reading_history rh FROM reading_history rh
JOIN media_items mi ON mi.id = rh.media_item_id JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = sqlc.arg('user_id') WHERE rh.user_id = sqlc.arg('user_id')
@@ -3129,3 +3135,106 @@ WHERE l.id = $1;
SELECT * FROM media_items SELECT * FROM media_items
WHERE library_id = $1 AND tags @> ARRAY[$2::text] WHERE library_id = $1 AND tags @> ARRAY[$2::text]
ORDER BY title ASC; ORDER BY title ASC;
-- ============================================================================
-- LIBRARY USAGE STATS (the /stats Library tab; the StatisticsService
-- LibraryUsage aggregate). media_items INVENTORY aggregates — what is in
-- the library, not what was read. Every query carries BOTH filters:
-- $1 = single-library scope (NULL = all visible libraries — "All")
-- $2 = the requester's visible-library id set (NULL = no filter; the
-- default-ALLOW family lens). A non-visible $1 is REJECTED at the
-- service/handler layer with 403 — the SQL filter is defense only.
-- + the healthy-item filter, matched to GetVisibleLibraryMediaCounts:
-- archived_at IS NULL AND missing_scan_count = 0 (dead rows lie).
-- ============================================================================
-- name: GetLibraryUsageCards :one
WITH usage_scope AS (
SELECT mi.author, mi.file_size, mi.page_count, mi.narrators, mi.audio_metadata
FROM media_items mi
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
)
SELECT
(SELECT COUNT(*) FROM usage_scope)::int AS items,
(SELECT COUNT(DISTINCT author) FROM usage_scope
WHERE author IS NOT NULL AND author <> '')::int AS authors,
(SELECT COUNT(DISTINCT n) FROM usage_scope s, unnest(s.narrators) AS n
WHERE n <> '')::int AS narrators,
(SELECT COALESCE(SUM(file_size), 0) FROM usage_scope)::bigint AS total_size_bytes,
(SELECT COALESCE(SUM(page_count), 0) FROM usage_scope)::bigint AS total_pages,
(SELECT COALESCE(
SUM((s.audio_metadata->>'duration_seconds')::float8), 0
)::bigint FROM usage_scope s
WHERE s.audio_metadata IS NOT NULL
AND s.audio_metadata ? 'duration_seconds') AS total_seconds;
-- name: GetLibraryUsageGenres :many
SELECT COALESCE(mi.genre, '') AS name, COUNT(*)::int AS row_count
FROM media_items mi
WHERE mi.genre IS NOT NULL AND mi.genre <> ''
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
GROUP BY mi.genre
ORDER BY row_count DESC, name ASC
LIMIT 5;
-- name: GetLibraryUsageAuthors :many
SELECT COALESCE(mi.author, '') AS name, COUNT(*)::int AS row_count
FROM media_items mi
WHERE mi.author IS NOT NULL AND mi.author <> ''
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
GROUP BY mi.author
ORDER BY row_count DESC, name ASC
LIMIT 10;
-- name: GetLibraryUsageLongestItems :many
-- The Longest Items panel, one query both modes: $3 = true ranks by
-- AUDIO duration (audio present in scope), false by page_count (text).
SELECT
mi.id::uuid AS media_item_id,
mi.title,
COALESCE(mi.author, '') AS author,
mi.library_id,
mi.cover_image_path,
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes,
COALESCE(mi.page_count, 0)::int AS page_count,
COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)::bigint AS total_seconds
FROM media_items mi
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
AND (CASE WHEN $3::bool
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0) > 0
ELSE COALESCE(mi.page_count, 0) > 0
END)
ORDER BY (CASE WHEN $3::bool
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)
ELSE COALESCE(mi.page_count, 0)::float8
END) DESC, mi.title ASC
LIMIT 5;
-- name: GetLibraryUsageLargestItems :many
SELECT
mi.id::uuid AS media_item_id,
mi.title,
COALESCE(mi.author, '') AS author,
mi.library_id,
mi.cover_image_path,
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes
FROM media_items mi
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND COALESCE(mi.file_size, 0) > 0
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
ORDER BY mi.file_size DESC, mi.title ASC
LIMIT 5;
+37
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services" "bookhoard/internal/services"
"errors"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
@@ -76,6 +77,42 @@ func (h *StatsHandler) ThisWeek(c *echo.Context) error {
return c.JSON(http.StatusOK, week) return c.JSON(http.StatusOK, week)
} }
// LibraryUsage handles GET /api/stats/library-usage?library_id=<uuid|omit>
// — the /stats Library tab: media_items INVENTORY aggregates (items,
// authors, narrators, size, audio hours, pages, top genres/authors,
// longest/largest items) for one library or the All-libraries scope.
// Family lens: a library_id outside the requester's visible set is a 403,
// never a silent pass (AGENTS.md standing rule; history: 431fec0).
// Omitted library_id aggregates across all visible libraries. No time
// dimension — that is the reading tab's business.
func (h *StatsHandler) LibraryUsage(c *echo.Context) error {
user, ok := mustUser(c)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
var libraryID pgtype.UUID
if raw := c.QueryParam("library_id"); raw != "" {
parsed, err := uuid.Parse(raw)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid library id")
}
libraryID = pgtype.UUID{Bytes: parsed, Valid: true}
}
stats, err := h.statsService.LibraryUsage(c.Request().Context(),
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
libraryID,
)
if err != nil {
if errors.Is(err, services.ErrLibraryNotVisible) {
return echo.NewHTTPError(http.StatusForbidden, "library not in the user's visible set")
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, stats)
}
// BookStats handles GET /api/stats/books/:id — the per-book card on the // BookStats handles GET /api/stats/books/:id — the per-book card on the
// book detail page (lifetime totals + recent sessions). // book detail page (lifetime totals + recent sessions).
func (h *StatsHandler) BookStats(c *echo.Context) error { func (h *StatsHandler) BookStats(c *echo.Context) error {
+3
View File
@@ -22,4 +22,7 @@ func registerAnalyticsRoutes(cfg *Config) {
stats.GET("/summary", cfg.StatsHandler.Summary) stats.GET("/summary", cfg.StatsHandler.Summary)
stats.GET("/this-week", cfg.StatsHandler.ThisWeek) stats.GET("/this-week", cfg.StatsHandler.ThisWeek)
stats.GET("/books/:id", cfg.StatsHandler.BookStats) stats.GET("/books/:id", cfg.StatsHandler.BookStats)
// Library tab: media_items inventory aggregates (what's in the
// library), scoped per request; reading_history is never touched here.
stats.GET("/library-usage", cfg.StatsHandler.LibraryUsage)
} }
+180 -6
View File
@@ -5,8 +5,10 @@ import (
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -80,10 +82,11 @@ func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
} }
type DailyStats struct { type DailyStats struct {
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
Pages int `json:"pages"` AudioMinutes int `json:"audio_minutes"` // listening wall-clock inside Minutes (media-mode split for the /stats side-by-side charts)
Sessions int `json:"sessions"` Pages int `json:"pages"`
Sessions int `json:"sessions"`
} }
type StreakStats struct { type StreakStats struct {
@@ -132,6 +135,7 @@ type UserSummaryStats struct {
StartDate string `json:"start_date"` StartDate string `json:"start_date"`
EndDate string `json:"end_date"` EndDate string `json:"end_date"`
TotalMinutes int `json:"total_minutes"` TotalMinutes int `json:"total_minutes"`
TotalAudioMinutes int `json:"total_audio_minutes"` // listening minutes inside TotalMinutes (mode-aware wording surface)
TotalPages int `json:"total_pages"` TotalPages int `json:"total_pages"`
SessionCount int `json:"session_count"` SessionCount int `json:"session_count"`
BooksActive int `json:"books_with_activity"` BooksActive int `json:"books_with_activity"`
@@ -198,6 +202,11 @@ func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pg
return ids, nil 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. // visibleOrNil maps an empty visible set to a NULL filter argument.
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID { func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
if len(ids) == 0 { if len(ids) == 0 {
@@ -270,11 +279,23 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
continue // defensive: outside the inclusive window 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 { if sess.TimeSpentSeconds.Valid {
seconds := int64(sess.TimeSpentSeconds.Int32) seconds := int64(sess.TimeSpentSeconds.Int32)
totalSessionSeconds += seconds totalSessionSeconds += seconds
summary.TotalMinutes += int(seconds / 60) minutes := int(seconds / 60)
entry.Minutes += int(seconds / 60) summary.TotalMinutes += minutes
entry.Minutes += minutes
if isAudio {
summary.TotalAudioMinutes += minutes
entry.AudioMinutes += minutes
}
} }
if sess.PagesRead.Valid { if sess.PagesRead.Valid {
summary.TotalPages += int(sess.PagesRead.Int32) summary.TotalPages += int(sess.PagesRead.Int32)
@@ -526,6 +547,159 @@ func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz
return out, nil 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 // BookStats is the per-book card: lifetime totals + the most recent
// sessions (bounded so the detail page never scans unbounded history). // 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) { func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) {
+1 -1
View File
@@ -213,7 +213,7 @@ templ Header(user User, currentPath string) {
</header> </header>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate <!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) --> builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=3"></script> <script type="module" src="/static/main.js?v=4"></script>
</div> </div>
} }
+1 -1
View File
@@ -631,7 +631,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=3\"></script></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=4\"></script></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+134 -57
View File
@@ -22,77 +22,154 @@ templ Stats(user User) {
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Stats</h1> <h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Stats</h1>
</div> </div>
<p class="text-sm" style="color: var(--text-secondary)">Days are bucketed in your timezone ({ user.Timezone }) · totals derive from your devices' sync pushes, not a stopwatch.</p> <p class="text-sm" style="color: var(--text-secondary)">Days are bucketed in your timezone ({ user.Timezone }) · totals derive from your devices' sync pushes, not a stopwatch.</p>
</div> <div class="flex gap-2 mt-4" id="stats-tab-row">
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3"> <button data-tab="reading" @click="selectTab('reading')" class="stats-tab-btn btn btn-secondary text-sm">Reading</button>
<div class="flex gap-2" id="stats-range-selector"> <button data-tab="library" @click="selectTab('library')" class="stats-tab-btn btn btn-secondary text-sm">Library</button>
<button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
<button data-range="year" @click="selectRange('year')" class="stats-range-btn btn btn-secondary text-sm">This year</button>
</div>
<p class="text-xs" style="color: var(--text-secondary)" id="stats-window-label"></p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Reading Time</h3>
<p id="stats-total-minutes" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Pages</h3>
<p id="stats-total-pages" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Streaks</h3>
<p id="stats-streaks" class="text-lg font-bold leading-tight mt-1" style="color: var(--text-primary)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Books Finished</h3>
<p id="stats-books-finished" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Most Active</h3>
<p id="stats-most-active" class="text-sm font-bold leading-snug mt-2" style="color: var(--text-primary)">–</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <!-- READING tab — behavior stats from sync pushes (all libraries) -->
<div class="card p-6"> <div id="stats-reading-section">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Daily Reading Minutes</h3> <div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="h-64"> <div class="flex gap-2" id="stats-range-selector">
<canvas id="stats-daily-minutes-chart"></canvas> <button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
<button data-range="year" @click="selectRange('year')" class="stats-range-btn btn btn-secondary text-sm">This year</button>
</div>
<p class="text-xs" style="color: var(--text-secondary)" id="stats-window-label"></p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Reading Time</h3>
<p id="stats-total-minutes" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)" id="stats-pages-card-label">Pages</h3>
<p id="stats-total-pages" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Streaks</h3>
<p id="stats-streaks" class="text-lg font-bold leading-tight mt-1" style="color: var(--text-primary)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Books Finished</h3>
<p id="stats-books-finished" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Most Active</h3>
<p id="stats-most-active" class="text-sm font-bold leading-snug mt-2" style="color: var(--text-primary)">–</p>
</div> </div>
</div> </div>
<div class="card p-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Pages per Day</h3> <div class="card p-6">
<div class="h-64"> <h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Daily Reading Minutes</h3>
<canvas id="stats-daily-pages-chart"></canvas> <div class="h-64">
<canvas id="stats-daily-minutes-chart"></canvas>
</div>
</div> </div>
<div class="card p-6" id="stats-pages-chart-panel">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)" id="stats-pages-chart-label">Pages per Day</h3>
<div class="h-64">
<canvas id="stats-daily-pages-chart"></canvas>
</div>
</div>
<div class="card p-6 hidden" id="stats-listening-chart-panel">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Listening per Day (hrs)</h3>
<div class="h-64">
<canvas id="stats-daily-listening-chart"></canvas>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3>
<div id="stats-library-breakdown" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Device</h3>
<p class="text-xs mb-3" style="color: var(--text-secondary)">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p>
<div id="stats-device-breakdown" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3>
<div id="stats-top-books" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div>
</div>
<div id="stats-empty" class="card p-8 text-center hidden">
<p class="text-sm mb-2" style="color: var(--text-primary)">No reading sessions recorded in this range yet.</p>
<p class="text-xs" style="color: var(--text-secondary)">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> <!-- LIBRARY tab — media_items inventory stats (what's IN the
<div class="card p-6"> scoped pool), no time dimension; ABS-style general book
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3> stats. Data arrives client-side; see stats.ts -->
<div id="stats-library-breakdown" class="space-y-3"> <div id="stats-library-section" class="hidden">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div> <div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<label class="text-sm" for="stats-library-select" style="color: var(--text-secondary)">Library</label>
<select id="stats-library-select" @change="selectLibrary($event.target.value)" class="btn btn-secondary text-sm bg-transparent">
<option value="">All libraries</option>
</select>
</div>
<p class="text-xs" style="color: var(--text-secondary)" id="stats-library-scope-label">Inventory statistics — what's in the library, not when it was read.</p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Items</h3>
<p id="lib-stat-items" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)" id="lib-stat-time-label">Hours</h3>
<p id="lib-stat-time" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Authors</h3>
<p id="lib-stat-authors" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Size (GB)</h3>
<p id="lib-stat-size" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Narrators</h3>
<p id="lib-stat-narrators" class="text-3xl font-bold" style="color: var(--accent)">–</p>
</div> </div>
</div> </div>
<div class="card p-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Device</h3> <div class="card p-6">
<p class="text-xs mb-3" style="color: var(--text-secondary)">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p> <h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Top Genres</h3>
<div id="stats-device-breakdown" class="space-y-3"> <div id="stats-lib-genres" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div> <div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Top Authors</h3>
<div id="stats-lib-authors" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div> </div>
</div> </div>
<div class="card p-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3> <div class="card p-6">
<div id="stats-top-books" class="space-y-3"> <h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)" id="stats-lib-longest-label">Longest Items (hrs)</h3>
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div> <div id="stats-lib-longest" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Largest Items</h3>
<div id="stats-lib-largest" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div id="stats-empty" class="card p-8 text-center hidden">
<p class="text-sm mb-2" style="color: var(--text-primary)">No reading sessions recorded in this range yet.</p>
<p class="text-xs" style="color: var(--text-secondary)">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p>
</div>
</div> </div>
@ErrorToast("") @ErrorToast("")
</body> </body>
+1 -1
View File
@@ -58,7 +58,7 @@ func Stats(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ") · totals derive from your devices' sync pushes, not a stopwatch.</p></div><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ") · totals derive from your devices' sync pushes, not a stopwatch.</p><div class=\"flex gap-2 mt-4\" id=\"stats-tab-row\"><button data-tab=\"reading\" @click=\"selectTab('reading')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Reading</button> <button data-tab=\"library\" @click=\"selectTab('library')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Library</button></div></div><!-- READING tab — behavior stats from sync pushes (all libraries) --><div id=\"stats-reading-section\"><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\" id=\"stats-pages-card-label\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\" id=\"stats-pages-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\" id=\"stats-pages-chart-label\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div><div class=\"card p-6 hidden\" id=\"stats-listening-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Listening per Day (hrs)</h3><div class=\"h-64\"><canvas id=\"stats-daily-listening-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every time a deviLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+359 -10
View File
@@ -5,10 +5,21 @@ import type { ChartConfiguration } from "chart.js";
// Reading statistics page (/stats): one summary fetch per range, charts // Reading statistics page (/stats): one summary fetch per range, charts
// hydrated client-side. Chart.js is dynamically imported so the chunk // hydrated client-side. Chart.js is dynamically imported so the chunk
// only loads on this page. // only loads on this page.
//
// Two tabs:
// - Reading — behavior stats from sync pushes, all libraries. Days
// bucket in the user's timezone. Minutes are the merged currency
// (audio wall-clock counts honestly); only the PAGES unit splits
// side-by-side (Pages · text vs Listening per Day), because a
// listen-only day must never render as a lying zero.
// - Library — media_items INVENTORY stats for one library (or the
// All-libraries aggregate): items, hours/pages, authors, size,
// narrators, top genres/authors, longest/largest items.
interface DailyEntry { interface DailyEntry {
date: string; date: string;
minutes: number; minutes: number;
audio_minutes: number;
pages: number; pages: number;
sessions: number; sessions: number;
} }
@@ -55,6 +66,7 @@ interface Summary {
start_date: string; start_date: string;
end_date: string; end_date: string;
total_minutes: number; total_minutes: number;
total_audio_minutes: number;
total_pages: number; total_pages: number;
session_count: number; session_count: number;
books_with_activity: number; books_with_activity: number;
@@ -71,13 +83,57 @@ interface Summary {
device_breakdown?: DeviceRow[]; device_breakdown?: DeviceRow[];
} }
// Library tab payload (GET /api/stats/library-usage) ----------------------
interface NameCount {
name: string;
count: number;
}
interface UsageItem {
media_item_id: string;
title: string;
author: string;
cover_image_path: string;
seconds: number; // audio duration (hours-mode)
pages: number; // text pages (pages-mode)
size: number; // always set
}
interface LibraryUsage {
library_id: string; // "" = all visible libraries
items: number;
authors: number;
narrators: number;
total_size_bytes: number;
total_seconds: number;
total_pages: number;
hours_mode: boolean;
genres: NameCount[];
top_authors: NameCount[];
longest_items: UsageItem[];
largest_items: UsageItem[];
}
interface VisibleLibrary {
id: string;
name: string;
type_name: string;
is_visible: boolean;
}
interface ChartLike { interface ChartLike {
destroy(): void; destroy(): void;
} }
let minutesChart: ChartLike | null = null; let minutesChart: ChartLike | null = null;
let pagesChart: ChartLike | null = null; let pagesChart: ChartLike | null = null;
let listeningChart: ChartLike | null = null;
let currentRange = "7"; let currentRange = "7";
let currentTab = "reading";
let currentLibraryID = ""; // "" = All libraries
let libraryOptionsLoaded = false;
let libraryDataLoaded = false;
// Page-scoped helpers ----------------------------------------------------- // Page-scoped helpers -----------------------------------------------------
@@ -110,6 +166,53 @@ function selectRange(range: string): void {
void loadSummary(); void loadSummary();
} }
// Tabs ---------------------------------------------------------------------
function markTabButtons(active: string): void {
document.querySelectorAll<HTMLElement>(".stats-tab-btn").forEach((btn) => {
const isActive = btn.dataset.tab === active;
btn.classList.toggle("btn-active", isActive);
btn.classList.toggle("opacity-50", !isActive);
});
}
function selectTab(tab: string): void {
currentTab = tab === "library" ? "library" : "reading";
markTabButtons(currentTab);
const reading = document.getElementById("stats-reading-section");
const library = document.getElementById("stats-library-section");
reading?.classList.toggle("hidden", currentTab !== "reading");
library?.classList.toggle("hidden", currentTab !== "library");
// Reading tab state addressable via the URL either way; the library
// selection rides its own param (set in selectLibrary).
syncUrl();
if (currentTab === "library" && !libraryOptionsLoaded) {
void loadLibraries();
}
if (currentTab === "library" && !libraryDataLoaded) {
void loadLibraryUsage();
}
}
function selectLibrary(libraryID: string): void {
currentLibraryID = libraryID || "";
libraryDataLoaded = false;
syncUrl();
void loadLibraryUsage();
}
function syncUrl(): void {
const params = new URLSearchParams();
if (currentTab !== "reading") params.set("tab", currentTab);
if (currentLibraryID) params.set("library_id", currentLibraryID);
const qs = params.toString();
window.history.replaceState(null, "", window.location.pathname + (qs ? `?${qs}` : ""));
}
// Summary fetch (Reading tab) -----------------------------------------------
async function loadSummary(): Promise<void> { async function loadSummary(): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
@@ -150,6 +253,47 @@ function themeVar(name: string, fallback: string): string {
return value || fallback; return value || fallback;
} }
function fmtHoursLabel(seconds: number): string {
return `${(seconds / 3600).toFixed(1)}`;
}
/**
* Mode-aware wording for the Pages card (the only top-line widget that
* carries a unit audio cannot share): in a mixed pool it counts TEXT
* pages explicitly; in an audio-only pool the slot carries the TIME
* equivalent instead of a lying zero. Display-time only — the source
* data is untouched.
*/
function renderPagesCard(summary: Summary): void {
const hasText = summary.total_pages > 0;
const hasAudio = summary.total_audio_minutes > 0;
const cardLabel = document.getElementById("stats-pages-card-label");
const chartLabel = document.getElementById("stats-pages-chart-label");
const pagesPanel = document.getElementById("stats-pages-chart-panel");
const listeningPanel = document.getElementById("stats-listening-chart-panel");
let label: string;
let value: string;
if (hasText) {
label = hasAudio ? "Pages · text" : "Pages";
value = `${summary.total_pages}`;
} else if (hasAudio) {
label = "Listening Time";
value = fmtMinutes(summary.total_audio_minutes);
} else {
label = "Pages";
value = "0";
}
if (cardLabel) cardLabel.textContent = label;
text("stats-total-pages", value);
if (chartLabel) chartLabel.textContent = hasAudio ? "Text Pages per Day" : "Pages per Day";
// Charts row: Listening per Day appears only when audio exists in the
// range; the text-pages chart drops out of an audio-only range.
pagesPanel?.classList.toggle("hidden", !hasText && hasAudio);
listeningPanel?.classList.toggle("hidden", !hasAudio);
}
function renderSummary(summary: Summary): void { function renderSummary(summary: Summary): void {
const hasData = summary.total_minutes > 0 || summary.session_count > 0; const hasData = summary.total_minutes > 0 || summary.session_count > 0;
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData); document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
@@ -158,7 +302,7 @@ function renderSummary(summary: Summary): void {
const mins = summary.total_minutes; const mins = summary.total_minutes;
text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`); text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`);
text("stats-total-pages", `${summary.total_pages}`); renderPagesCard(summary);
text( text(
"stats-streaks", "stats-streaks",
summary.streak.current > 0 || summary.streak.longest > 0 summary.streak.current > 0 || summary.streak.longest > 0
@@ -203,8 +347,10 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
minutesChart?.destroy(); minutesChart?.destroy();
pagesChart?.destroy(); pagesChart?.destroy();
listeningChart?.destroy();
minutesChart = null; minutesChart = null;
pagesChart = null; pagesChart = null;
listeningChart = null;
const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null; const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null;
if (minutesCanvas) { if (minutesCanvas) {
@@ -238,6 +384,27 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
options: baseOptions, options: baseOptions,
}); });
} }
// Listening per Day (hrs) — the audio half of the side-by-side pages
// split; its panel is hidden by renderPagesCard unless the range has
// audio minutes.
const listeningCanvas = document.getElementById("stats-daily-listening-chart") as HTMLCanvasElement | null;
if (listeningCanvas) {
listeningChart = new ChartCtor(listeningCanvas, {
type: "bar",
data: {
labels,
datasets: [
{
data: daily.map((d) => (d.audio_minutes || 0) / 60),
backgroundColor: accent,
borderRadius: 4,
},
],
},
options: baseOptions,
});
}
} }
function renderLibraries(rows: LibraryRow[]): void { function renderLibraries(rows: LibraryRow[]): void {
@@ -246,7 +413,7 @@ function renderLibraries(rows: LibraryRow[]): void {
renderBars(container, rows.map((r) => ({ renderBars(container, rows.map((r) => ({
label: r.name, label: r.name,
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`, sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
minutes: r.minutes, value: r.minutes,
}))); })));
} }
@@ -256,7 +423,7 @@ function renderDevices(rows: DeviceRow[]): void {
renderBars(container, rows.map((r) => ({ renderBars(container, rows.map((r) => ({
label: r.name || "Web", label: r.name || "Web",
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`, sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
minutes: r.minutes, value: r.minutes,
}))); })));
} }
@@ -267,17 +434,19 @@ function fmtMinutes(minutes: number): string {
} }
function renderBars( function renderBars(
container: HTMLElement, container: HTMLElement | null,
rows: Array<{ label: string; sub: string; minutes: number }>, rows: Array<{ label: string; sub: string; value: number }>,
emptyText = "Nothing recorded in this range yet.",
): void { ): void {
if (!container) return;
if (rows.length === 0) { if (rows.length === 0) {
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">Nothing recorded in this range yet.</p>`; container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">${emptyText}</p>`;
return; return;
} }
const max = Math.max(...rows.map((r) => r.minutes), 1); const max = Math.max(...rows.map((r) => r.value), 1);
container.innerHTML = rows container.innerHTML = rows
.map((row) => { .map((row) => {
const pct = Math.round((row.minutes / max) * 100); const pct = Math.round((row.value / max) * 100);
return ` return `
<div> <div>
<div class="flex justify-between text-sm mb-1"> <div class="flex justify-between text-sm mb-1">
@@ -316,6 +485,176 @@ function renderTopBooks(books: TopBook[]): void {
.join(""); .join("");
} }
// Library tab ---------------------------------------------------------------
async function loadLibraries(): Promise<VisibleLibrary[]> {
const token = localStorage.getItem("token");
if (!token) return [];
const res = await fetch("/api/libraries/visibility", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
showToast("Failed to load libraries", "error");
return [];
}
const rows = (await res.json()) as VisibleLibrary[];
libraryOptionsLoaded = true;
fillLibrarySelector(rows);
return rows;
}
function fillLibrarySelector(rows: VisibleLibrary[]): void {
const select = document.getElementById("stats-library-select") as HTMLSelectElement | null;
if (!select) return;
select.innerHTML = `<option value="">All libraries</option>` +
rows
.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`)
.join("");
// Honor a URL-seeded selection initStatsPage may have set.
if (currentLibraryID) select.value = currentLibraryID;
}
async function loadLibraryUsage(): Promise<void> {
if (libraryDataLoaded) return;
const token = localStorage.getItem("token");
if (!token) return;
libraryDataLoaded = true;
const qs = currentLibraryID ? `?library_id=${encodeURIComponent(currentLibraryID)}` : "";
const targets = [
"lib-stat-items",
"lib-stat-time",
"lib-stat-authors",
"lib-stat-size",
"lib-stat-narrators",
];
targets.forEach((id) => text(id, "–"));
for (const id of ["stats-lib-genres", "stats-lib-authors", "stats-lib-longest", "stats-lib-largest"]) {
const el = document.getElementById(id);
if (el) el.innerHTML = `<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>`;
}
try {
const res = await fetch(`/api/stats/library-usage${qs}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 403) {
showToast("Library not visible to you", "error");
return;
}
if (!res.ok) throw new Error(String(res.status));
renderLibraryUsage((await res.json()) as LibraryUsage);
} catch (error) {
libraryDataLoaded = false; // allow a retry on next activation
console.error("Failed to load library stats:", error);
showToast("Failed to load library stats", "error");
}
}
function fmtBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB`;
return `${bytes} B`;
}
function renderLibraryUsage(payload: LibraryUsage): void {
const scopeLabel = document.getElementById("stats-library-scope-label");
if (scopeLabel) {
scopeLabel.textContent = payload.library_id
? "Inventory statistics for this library — what's in it, not when it was read."
: "Inventory statistics across every visible library.";
}
text("lib-stat-items", `${payload.items}`);
text("lib-stat-authors", `${payload.authors}`);
text("lib-stat-narrators", `${payload.narrators}`);
// The Hours/Pages card adapts to the scope: audiobooks carry real
// durations; text libraries rank in pages instead.
const timeLabel = document.getElementById("lib-stat-time-label");
if (payload.hours_mode) {
if (timeLabel) timeLabel.textContent = "Hours";
text("lib-stat-time", fmtHoursLabel(payload.total_seconds));
} else {
if (timeLabel) timeLabel.textContent = "Pages";
text("lib-stat-time", `${payload.total_pages}`);
}
const gb = payload.total_size_bytes / 1e9;
text("lib-stat-size", gb >= 0.1 ? gb.toFixed(1) : (payload.total_size_bytes / 1e6).toFixed(1) + " MB");
const itemTotal = Math.max(payload.items, 1);
renderBars(
document.getElementById("stats-lib-genres"),
payload.genres.map((g) => ({
label: g.name,
sub: `${g.count} ${g.count === 1 ? "item" : "items"} · ${Math.round((g.count / itemTotal) * 100)}%`,
value: g.count,
})),
"No genres recorded in this pool yet.",
);
renderBars(
document.getElementById("stats-lib-authors"),
payload.top_authors.map((a) => ({
label: a.name,
sub: `${a.count} ${a.count === 1 ? "book" : "books"} · ${Math.round((a.count / itemTotal) * 100)}%`,
value: a.count,
})),
"No authors recorded in this pool yet.",
);
renderUsageItems("stats-lib-longest", "stats-lib-longest-label", payload.longest_items, payload.hours_mode);
renderUsageItems("stats-lib-largest", null, payload.largest_items, payload.hours_mode, true);
}
function renderUsageItems(
containerID: string,
labelID: string | null,
items: UsageItem[],
hoursMode: boolean,
largest = false,
): void {
const container = document.getElementById(containerID);
if (!container) return;
if (labelID && !largest) {
const label = document.getElementById(labelID);
if (label) label.textContent = hoursMode ? "Longest Items (hrs)" : "Longest Items (pages)";
}
if (items.length === 0) {
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">Nothing to rank in this pool yet.</p>`;
return;
}
const values = items.map((i) => (largest ? i.size : hoursMode ? i.seconds : i.pages));
const max = Math.max(...values, 1);
container.innerHTML = items
.map((item, i) => {
const value = largest
? fmtBytes(item.size)
: hoursMode
? (item.seconds / 3600).toFixed(1)
: `${item.pages}`;
const barPct = Math.round((values[i] / max) * 100);
return `
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
<div class="flex items-center gap-3">
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
</span>
<span class="flex-1 min-w-0">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span>
<span class="block text-xs truncate" style="color: var(--text-secondary)">${escapeHtml(item.author || "")}</span>
</span>
<span class="text-xs shrink-0" style="color: var(--text-secondary)">${i + 1}.</span>
<span class="text-sm font-bold shrink-0" style="color: var(--text-primary)">${escapeHtml(value)}</span>
</div>
<div class="h-2 rounded-full overflow-hidden mt-2" style="background-color: var(--bg-primary)">
<div class="h-full rounded-full" style="width: ${barPct}%; background-color: var(--accent)"></div>
</div>
</a>`;
})
.join("");
}
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value return value
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@@ -325,16 +664,26 @@ function escapeHtml(value: string): string {
} }
function initStatsPage(): void { function initStatsPage(): void {
const initial = new URLSearchParams(window.location.search).get("range") || "7"; const params = new URLSearchParams(window.location.search);
const initial = params.get("range") || "7";
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7"; currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
markRangeButtons(currentRange); markRangeButtons(currentRange);
const libParam = params.get("library_id") || "";
if (/^[0-9a-f-]{36}$/i.test(libParam)) currentLibraryID = libParam;
markTabButtons("reading");
void loadSummary(); void loadSummary();
selectTab(params.get("tab") || "reading");
} }
export { initStatsPage, loadSummary, renderSummary, selectRange }; export { initStatsPage, loadSummary, renderSummary, selectRange, selectTab, selectLibrary };
Alpine.data("stats", () => ({ Alpine.data("stats", () => ({
initStatsPage, initStatsPage,
loadSummary, loadSummary,
selectRange, selectRange,
selectTab,
selectLibrary,
})); }));