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:
1 parent
a222ef5c27
commit
ce366b1604
13 files changed
+1436
-21
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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=<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
|
||||
// book detail page (lifetime totals + recent sessions).
|
||||
func (h *StatsHandler) BookStats(c *echo.Context) error {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -82,6 +84,7 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -213,7 +213,7 @@ templ Header(user User, currentPath string) {
|
||||
</header>
|
||||
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
|
||||
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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+81
-4
@@ -22,7 +22,13 @@ templ Stats(user User) {
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Stats</h1>
|
||||
</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>
|
||||
<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>
|
||||
@@ -38,7 +44,7 @@ templ Stats(user User) {
|
||||
<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>
|
||||
<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">
|
||||
@@ -54,19 +60,25 @@ templ Stats(user User) {
|
||||
<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="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">
|
||||
<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" 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">
|
||||
@@ -94,6 +106,71 @@ templ Stats(user User) {
|
||||
<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>
|
||||
<!-- LIBRARY tab — media_items inventory stats (what's IN the
|
||||
scoped pool), no time dimension; ABS-style general book
|
||||
stats. Data arrives client-side; see stats.ts -->
|
||||
<div id="stats-library-section" class="hidden">
|
||||
<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 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)">Top Genres</h3>
|
||||
<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>
|
||||
</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 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)" id="stats-lib-longest-label">Longest Items (hrs)</h3>
|
||||
<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>
|
||||
@ErrorToast("")
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -58,7 +58,7 @@ func Stats(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+359
-10
@@ -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<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> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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 = `<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;
|
||||
}
|
||||
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 `
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
@@ -316,6 +485,176 @@ function renderTopBooks(books: TopBook[]): void {
|
||||
.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 {
|
||||
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,
|
||||
}));
|
||||
Reference in new issue
Block a user