From ce366b16045fe392605e879c5ea6b940be821912 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 16 Oct 2026 10:14:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(stats):=20Library=20tab=20=E2=80=94=20per-?= =?UTF-8?q?library=20inventory=20stats=20+=20audio-aware=20Reading=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=; 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. --- cmd/server/tests/statistics_test.go | 247 ++++++++++++ docs/developer/api/stats/get_library_usage.md | 103 +++++ internal/database/querier.go | 23 ++ internal/database/queries.sql.go | 295 +++++++++++++- internal/database/queries/queries.sql | 111 +++++- internal/handlers/stats.go | 37 ++ internal/router/analytics.go | 3 + internal/services/statistics_service.go | 186 ++++++++- templates/header.templ | 2 +- templates/header_templ.go | 2 +- templates/stats.templ | 191 ++++++--- templates/stats_templ.go | 2 +- web/src/stats.ts | 369 +++++++++++++++++- 13 files changed, 1493 insertions(+), 78 deletions(-) create mode 100644 docs/developer/api/stats/get_library_usage.md diff --git a/cmd/server/tests/statistics_test.go b/cmd/server/tests/statistics_test.go index 64593b83..491d1b2d 100644 --- a/cmd/server/tests/statistics_test.go +++ b/cmd/server/tests/statistics_test.go @@ -517,3 +517,250 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) { defer func() { _ = resp.Body.Close() }() 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") +} diff --git a/docs/developer/api/stats/get_library_usage.md b/docs/developer/api/stats/get_library_usage.md new file mode 100644 index 00000000..7d838098 --- /dev/null +++ b/docs/developer/api/stats/get_library_usage.md @@ -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 | diff --git a/internal/database/querier.go b/internal/database/querier.go index 1c2587ea..85be4d40 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -80,6 +80,9 @@ type Querier interface { CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error) CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error) // 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) // MEDIA ITEM FORMATS QUERIES // Create media item format @@ -88,6 +91,8 @@ type Querier interface { // (library_id, file_path) this inserts NOTHING and returns no row // (pgx.ErrNoRows) — the handler then answers 200 with the existing row // 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) // Media Notes queries CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error) @@ -250,6 +255,24 @@ type Querier interface { GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error) // Library Types queries 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) // ============================================================================ // LIBRARY WITH TYPE INFO QUERIES diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 5e950029..0df46f52 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1037,6 +1037,9 @@ type CreateMediaItemParams struct { } // 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) { row := q.db.QueryRow(ctx, CreateMediaItem, arg.LibraryID, @@ -1273,6 +1276,8 @@ type CreateMediaItemIfNewParams struct { // (library_id, file_path) this inserts NOTHING and returns no row // (pgx.ErrNoRows) — the handler then answers 200 with the existing row // 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) { row := q.db.QueryRow(ctx, CreateMediaItemIfNew, arg.LibraryID, @@ -4996,6 +5001,291 @@ func (q *Queries) GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error) { 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 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.reading_session_start, rh.reading_session_end, rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at, - mi.title, mi.author, mi.library_id, mi.cover_image_path + mi.title, mi.author, mi.library_id, mi.cover_image_path, + mi.library_type_name FROM reading_history rh JOIN media_items mi ON mi.id = rh.media_item_id WHERE rh.user_id = $1 @@ -9021,6 +9312,7 @@ type GetUserReadingSessionsRangeRow struct { Author pgtype.Text `db:"author" json:"author"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` } // STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns. @@ -9060,6 +9352,7 @@ func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserRe &i.Author, &i.LibraryID, &i.CoverImagePath, + &i.LibraryTypeName, ); err != nil { return nil, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index a32b8cfa..b854585f 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -149,6 +149,9 @@ GROUP BY l.id; -- Media Items queries -- 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) 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() @@ -159,6 +162,8 @@ RETURNING *; -- (library_id, file_path) this inserts NOTHING and returns no row -- (pgx.ErrNoRows) — the handler then answers 200 with the existing row -- 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) 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 @@ -1543,7 +1548,8 @@ SELECT rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage, rh.reading_session_start, rh.reading_session_end, rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at, - mi.title, mi.author, mi.library_id, mi.cover_image_path + mi.title, mi.author, mi.library_id, mi.cover_image_path, + mi.library_type_name FROM reading_history rh JOIN media_items mi ON mi.id = rh.media_item_id WHERE rh.user_id = sqlc.arg('user_id') @@ -3129,3 +3135,106 @@ WHERE l.id = $1; SELECT * FROM media_items WHERE library_id = $1 AND tags @> ARRAY[$2::text] 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; diff --git a/internal/handlers/stats.go b/internal/handlers/stats.go index e30e9f79..127d1bab 100644 --- a/internal/handlers/stats.go +++ b/internal/handlers/stats.go @@ -3,6 +3,7 @@ package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" + "errors" "net/http" "github.com/google/uuid" @@ -76,6 +77,42 @@ func (h *StatsHandler) ThisWeek(c *echo.Context) error { return c.JSON(http.StatusOK, week) } +// LibraryUsage handles GET /api/stats/library-usage?library_id= +// — 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 // book detail page (lifetime totals + recent sessions). func (h *StatsHandler) BookStats(c *echo.Context) error { diff --git a/internal/router/analytics.go b/internal/router/analytics.go index 76b59490..12a8797b 100644 --- a/internal/router/analytics.go +++ b/internal/router/analytics.go @@ -22,4 +22,7 @@ func registerAnalyticsRoutes(cfg *Config) { stats.GET("/summary", cfg.StatsHandler.Summary) stats.GET("/this-week", cfg.StatsHandler.ThisWeek) 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) } diff --git a/internal/services/statistics_service.go b/internal/services/statistics_service.go index 1eef3b91..7f28c974 100644 --- a/internal/services/statistics_service.go +++ b/internal/services/statistics_service.go @@ -5,8 +5,10 @@ import ( "bookhoard/internal/utils" "context" "encoding/json" + "errors" "fmt" "log" + "strings" "time" "github.com/google/uuid" @@ -80,10 +82,11 @@ func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) { } type DailyStats struct { - Date string `json:"date"` // YYYY-MM-DD, in the user's timezone - Minutes int `json:"minutes"` - Pages int `json:"pages"` - Sessions int `json:"sessions"` + Date string `json:"date"` // YYYY-MM-DD, in the user's timezone + Minutes int `json:"minutes"` + AudioMinutes int `json:"audio_minutes"` // listening wall-clock inside Minutes (media-mode split for the /stats side-by-side charts) + Pages int `json:"pages"` + Sessions int `json:"sessions"` } type StreakStats struct { @@ -132,6 +135,7 @@ type UserSummaryStats struct { StartDate string `json:"start_date"` EndDate string `json:"end_date"` TotalMinutes int `json:"total_minutes"` + TotalAudioMinutes int `json:"total_audio_minutes"` // listening minutes inside TotalMinutes (mode-aware wording surface) TotalPages int `json:"total_pages"` SessionCount int `json:"session_count"` BooksActive int `json:"books_with_activity"` @@ -198,6 +202,11 @@ func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pg return ids, nil } +// ErrLibraryNotVisible is returned by LibraryUsage when the caller asked +// for a scoped library outside the requester's visible set — the handler +// maps it to 403 (never silently pass; the AGENTS.md family-lens rule). +var ErrLibraryNotVisible = errors.New("library not in the user's visible set") + // visibleOrNil maps an empty visible set to a NULL filter argument. func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID { if len(ids) == 0 { @@ -270,11 +279,23 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, continue // defensive: outside the inclusive window } + // Media mode (the side-by-side wording split): minutes are the + // merged currency everywhere; PAGES only exist for text. The + // joined item's library type is the discriminator (denormalized + // column, trigger-maintained) — 'audiobooks' = audio wall-clock, + // everything else = text. + isAudio := strings.EqualFold(strings.TrimSpace(sess.LibraryTypeName.String), "audiobooks") + if sess.TimeSpentSeconds.Valid { seconds := int64(sess.TimeSpentSeconds.Int32) totalSessionSeconds += seconds - summary.TotalMinutes += int(seconds / 60) - entry.Minutes += int(seconds / 60) + minutes := int(seconds / 60) + summary.TotalMinutes += minutes + entry.Minutes += minutes + if isAudio { + summary.TotalAudioMinutes += minutes + entry.AudioMinutes += minutes + } } if sess.PagesRead.Valid { summary.TotalPages += int(sess.PagesRead.Int32) @@ -526,6 +547,159 @@ func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz return out, nil } +// LibraryUsage types — the /stats Library tab (Audiobookshelf-style +// GENERAL book stats). INVENTORY facts from media_items: what is in the +// scoped pool, not what was read. Unlike the reading aggregates these are +// not calendar-bound; the reading tab keeps its own range selector. +type NameCountStats struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type UsageItemStats struct { + MediaItemID string `json:"media_item_id"` + Title string `json:"title"` + Author string `json:"author"` + CoverImage string `json:"cover_image_path"` + Seconds float64 `json:"seconds"` // audio duration (0 unless hours_mode) + PageCount int `json:"pages"` // text pages (0 unless !hours_mode) + SizeBytes int64 `json:"size"` // always set, both modes +} + +// LibraryUsageStats is the whole tab payload for one scope: "" LibraryID +// = the All-libraries aggregate across the requester's visible set. +// HoursMode says which unit the LONGEST panel (and the runtime card +// swap) render: true = audio hours (audio present in scope), false = +// text pages. TotalSeconds/TotalPages both always carry their sums so +// the client never has to infer. +type LibraryUsageStats struct { + LibraryID string `json:"library_id"` + Items int `json:"items"` + Authors int `json:"authors"` + Narrators int `json:"narrators"` + TotalSizeBytes int64 `json:"total_size_bytes"` + TotalSeconds int64 `json:"total_seconds"` + TotalPages int `json:"total_pages"` + HoursMode bool `json:"hours_mode"` + Genres []NameCountStats `json:"genres"` + AuthorsTop []NameCountStats `json:"top_authors"` + LongestItems []UsageItemStats `json:"longest_items"` + LargestItems []UsageItemStats `json:"largest_items"` +} + +// LibraryUsage builds the /stats Library tab payload for one scope. +// libraryID scopes to one library (invalid = all visible). An explicit +// library OUTSIDE the requester's visible set returns ErrLibraryNotVisible +// (handler → 403; never silently pass — the family-lens standing rule). +func (s *StatisticsService) LibraryUsage(ctx context.Context, userID pgtype.UUID, libraryID pgtype.UUID) (LibraryUsageStats, error) { + visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID) + if err != nil { + return LibraryUsageStats{}, err + } + visible := visibleOrNil(visibleIDs) + + if libraryID.Valid { + found := false + for _, id := range visibleIDs { + if id == libraryID { + found = true + break + } + } + if !found { + return LibraryUsageStats{}, ErrLibraryNotVisible + } + } + + params := database.GetLibraryUsageCardsParams{ + Column1: libraryID, + Column2: visible, + } + cards, err := s.db.GetLibraryUsageCards(ctx, params) + if err != nil { + return LibraryUsageStats{}, fmt.Errorf("failed to load library cards: %w", err) + } + + out := LibraryUsageStats{ + LibraryID: "", + Items: int(cards.Items), + Authors: int(cards.Authors), + Narrators: int(cards.Narrators), + TotalSizeBytes: cards.TotalSizeBytes, + TotalSeconds: cards.TotalSeconds, + TotalPages: int(cards.TotalPages), + HoursMode: cards.TotalSeconds > 0, + Genres: []NameCountStats{}, + AuthorsTop: []NameCountStats{}, + LongestItems: []UsageItemStats{}, + LargestItems: []UsageItemStats{}, + } + if libraryID.Valid { + out.LibraryID = uuid.UUID(libraryID.Bytes).String() + } + + genres, err := s.db.GetLibraryUsageGenres(ctx, database.GetLibraryUsageGenresParams(params)) + if err != nil { + return LibraryUsageStats{}, fmt.Errorf("failed to load library genres: %w", err) + } + for _, row := range genres { + out.Genres = append(out.Genres, NameCountStats{Name: row.Name, Count: int(row.RowCount)}) + } + + authors, err := s.db.GetLibraryUsageAuthors(ctx, database.GetLibraryUsageAuthorsParams(params)) + if err != nil { + return LibraryUsageStats{}, fmt.Errorf("failed to load library authors: %w", err) + } + for _, row := range authors { + out.AuthorsTop = append(out.AuthorsTop, NameCountStats{Name: row.Name, Count: int(row.RowCount)}) + } + + // The longest panel ranks by hours when audio data exists in scope, + // else by pages (text libraries; the "Longest Items" equivalent that + // actually means something there). + longestRows, err := s.db.GetLibraryUsageLongestItems(ctx, database.GetLibraryUsageLongestItemsParams{ + Column1: libraryID, + Column2: visible, + Column3: out.HoursMode, + }) + if err != nil { + return LibraryUsageStats{}, fmt.Errorf("failed to load longest items: %w", err) + } + for _, row := range longestRows { + entry := UsageItemStats{ + Title: row.Title, + Author: row.Author, + Seconds: float64(row.TotalSeconds), + PageCount: int(row.PageCount), + SizeBytes: row.FileSizeBytes, + } + if row.MediaItemID.Valid { + entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String() + entry.CoverImage = utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath) + } + out.LongestItems = append(out.LongestItems, entry) + } + + largestRows, err := s.db.GetLibraryUsageLargestItems(ctx, database.GetLibraryUsageLargestItemsParams(params)) + if err != nil { + return LibraryUsageStats{}, fmt.Errorf("failed to load largest items: %w", err) + } + for _, row := range largestRows { + entry := UsageItemStats{ + Title: row.Title, + Author: row.Author, + SizeBytes: row.FileSizeBytes, + } + if row.MediaItemID.Valid { + entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String() + entry.CoverImage = utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath) + } + out.LargestItems = append(out.LargestItems, entry) + } + + return out, nil +} + // BookStats is the per-book card: lifetime totals + the most recent // sessions (bounded so the detail page never scans unbounded history). func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) { diff --git a/templates/header.templ b/templates/header.templ index aaee11b5..7d0f8288 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -213,7 +213,7 @@ templ Header(user User, currentPath string) { - + } diff --git a/templates/header_templ.go b/templates/header_templ.go index 6fcfdda0..5b591656 100644 --- a/templates/header_templ.go +++ b/templates/header_templ.go @@ -631,7 +631,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/stats.templ b/templates/stats.templ index 13f7d5c4..02c6c438 100644 --- a/templates/stats.templ +++ b/templates/stats.templ @@ -22,77 +22,154 @@ templ Stats(user User) {

Reading Stats

Days are bucketed in your timezone ({ user.Timezone }) · totals derive from your devices' sync pushes, not a stopwatch.

- -
-
- - - - -
-

-
-
-
-

Reading Time

-

–

-
-
-

Pages

-

–

-
-
-

Streaks

-

–

-
-
-

Books Finished

-

–

-
-
-

Most Active

-

–

+
+ +
-
-
-

Daily Reading Minutes

-
- + +
+
+
+ + + + +
+

+
+
+
+

Reading Time

+

–

+
+
+

Pages

+

–

+
+
+

Streaks

+

–

+
+
+

Books Finished

+

–

+
+
+

Most Active

+

–

-
-

Pages per Day

-
- +
+
+

Daily Reading Minutes

+
+ +
+
+

Pages per Day

+
+ +
+
+ +
+
+
+

By Library

+
+
Loading…
+
+
+
+

By Device

+

Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).

+
+
Loading…
+
+
+
+

Most Read

+
+
Loading…
+
+
+
+
-
-
-

By Library

-
-
Loading…
+ +

Reading Time

–

Pages

–

Streaks

–

Books Finished

–

Most Active

–

Daily Reading Minutes

Pages per Day

Listening per Day (hrs)

By Library

Loading…

By Device

Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).

Loading…

Most Read

Loading…

No reading sessions recorded in this range yet.

Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).

Inventory statistics — what's in the library, not when it was read.

Items

–

Hours

–

Authors

–

Size (GB)

–

Narrators

–

Top Genres

Loading…

Top Authors

Loading…

Longest Items (hrs)

Loading…

Largest Items

Loading…
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/stats.ts b/web/src/stats.ts index bd9390f0..b084bdff 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -5,10 +5,21 @@ import type { ChartConfiguration } from "chart.js"; // Reading statistics page (/stats): one summary fetch per range, charts // hydrated client-side. Chart.js is dynamically imported so the chunk // 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 { date: string; minutes: number; + audio_minutes: number; pages: number; sessions: number; } @@ -55,6 +66,7 @@ interface Summary { start_date: string; end_date: string; total_minutes: number; + total_audio_minutes: number; total_pages: number; session_count: number; books_with_activity: number; @@ -71,13 +83,57 @@ interface Summary { 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 { destroy(): void; } let minutesChart: ChartLike | null = null; let pagesChart: ChartLike | null = null; +let listeningChart: ChartLike | null = null; let currentRange = "7"; +let currentTab = "reading"; +let currentLibraryID = ""; // "" = All libraries +let libraryOptionsLoaded = false; +let libraryDataLoaded = false; // Page-scoped helpers ----------------------------------------------------- @@ -110,6 +166,53 @@ function selectRange(range: string): void { void loadSummary(); } +// Tabs --------------------------------------------------------------------- + +function markTabButtons(active: string): void { + document.querySelectorAll(".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 { const token = localStorage.getItem("token"); if (!token) return; @@ -150,6 +253,47 @@ function themeVar(name: string, fallback: string): string { 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 { const hasData = summary.total_minutes > 0 || summary.session_count > 0; document.getElementById("stats-empty")?.classList.toggle("hidden", hasData); @@ -158,7 +302,7 @@ function renderSummary(summary: Summary): void { const mins = summary.total_minutes; 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( "stats-streaks", summary.streak.current > 0 || summary.streak.longest > 0 @@ -203,8 +347,10 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { minutesChart?.destroy(); pagesChart?.destroy(); + listeningChart?.destroy(); minutesChart = null; pagesChart = null; + listeningChart = null; const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null; if (minutesCanvas) { @@ -238,6 +384,27 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { 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 { @@ -246,7 +413,7 @@ function renderLibraries(rows: LibraryRow[]): void { renderBars(container, rows.map((r) => ({ label: r.name, 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) => ({ label: r.name || "Web", 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( - container: HTMLElement, - rows: Array<{ label: string; sub: string; minutes: number }>, + container: HTMLElement | null, + rows: Array<{ label: string; sub: string; value: number }>, + emptyText = "Nothing recorded in this range yet.", ): void { + if (!container) return; if (rows.length === 0) { - container.innerHTML = `

Nothing recorded in this range yet.

`; + container.innerHTML = `

${emptyText}

`; return; } - const max = Math.max(...rows.map((r) => r.minutes), 1); + const max = Math.max(...rows.map((r) => r.value), 1); container.innerHTML = rows .map((row) => { - const pct = Math.round((row.minutes / max) * 100); + const pct = Math.round((row.value / max) * 100); return `
@@ -316,6 +485,176 @@ function renderTopBooks(books: TopBook[]): void { .join(""); } +// Library tab --------------------------------------------------------------- + +async function loadLibraries(): Promise { + 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 = `` + + rows + .map((r) => ``) + .join(""); + // Honor a URL-seeded selection initStatsPage may have set. + if (currentLibraryID) select.value = currentLibraryID; +} + +async function loadLibraryUsage(): Promise { + 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 = `
Loading…
`; + } + + 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 = `

Nothing to rank in this pool yet.

`; + 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 ` + +
+ + + + + ${escapeHtml(item.title)} + ${escapeHtml(item.author || "")} + + ${i + 1}. + ${escapeHtml(value)} +
+
+
+
+
`; + }) + .join(""); +} + function escapeHtml(value: string): string { return value .replaceAll("&", "&") @@ -325,16 +664,26 @@ function escapeHtml(value: string): string { } 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"; markRangeButtons(currentRange); + + const libParam = params.get("library_id") || ""; + if (/^[0-9a-f-]{36}$/i.test(libParam)) currentLibraryID = libParam; + + markTabButtons("reading"); void loadSummary(); + + selectTab(params.get("tab") || "reading"); } -export { initStatsPage, loadSummary, renderSummary, selectRange }; +export { initStatsPage, loadSummary, renderSummary, selectRange, selectTab, selectLibrary }; Alpine.data("stats", () => ({ initStatsPage, loadSummary, selectRange, + selectTab, + selectLibrary, }));