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.
137 lines
4.5 KiB
Go
137 lines
4.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
// StatsHandler serves the reading-statistics API (/api/stats/*) backed
|
|
// by the StatisticsService. The server owns every conversion: clients
|
|
// receive user-timezone-rendered strings plus raw minute/page counts.
|
|
type StatsHandler struct {
|
|
statsService *services.StatisticsService
|
|
}
|
|
|
|
func NewStatsHandler(statsService *services.StatisticsService) *StatsHandler {
|
|
return &StatsHandler{statsService: statsService}
|
|
}
|
|
|
|
func mustUser(c *echo.Context) (database.Users, bool) {
|
|
user, ok := c.Get("user").(database.Users)
|
|
return user, ok
|
|
}
|
|
|
|
// Summary handles GET /api/stats/summary?range=7|30|90|year — the /stats
|
|
// page's complete payload: totals, daily arrays, streaks, most-active
|
|
// day, top books, per-library breakdown.
|
|
func (h *StatsHandler) Summary(c *echo.Context) error {
|
|
user, ok := mustUser(c)
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
summary, err := h.statsService.UserSummary(c.Request().Context(),
|
|
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
user.Timezone.String,
|
|
c.QueryParam("range"),
|
|
)
|
|
if err != nil {
|
|
if err.Error()[:7] == "invalid" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, summary)
|
|
}
|
|
|
|
// ThisWeek handles GET /api/stats/this-week — the dashboard widget's
|
|
// data: rolling 7-day totals vs the previous seven days, plus streak.
|
|
// An optional ?library_id scopes the week to one library (the
|
|
// dashboard's library switcher drives it).
|
|
func (h *StatsHandler) ThisWeek(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}
|
|
}
|
|
week, err := h.statsService.ThisWeek(c.Request().Context(),
|
|
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
user.Timezone.String,
|
|
libraryID,
|
|
)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.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 {
|
|
user, ok := mustUser(c)
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
mediaItemID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item id")
|
|
}
|
|
stats, err := h.statsService.BookStats(c.Request().Context(),
|
|
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
mediaItemID,
|
|
user.Timezone.String,
|
|
)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, stats)
|
|
}
|