Three dashboard complaints, one build: - The widget collapses to a single summary row (This Week · minutes · pages · sessions · books · streak · Full stats) on every screen size and expands to the full grid via a details/summary toggle — the carousel sections start immediately below it again. - The 'Your Library' heading moved into the library switcher subbar (LibrarySwitcher gains an optional title param; collections/series keep the plain 'Library' label). The standalone heading block is gone. - Root-caused the JS-switch vanishing: renderDashboardCollections replaces ALL of #collections-container's innerHTML, so anything server-rendered inside it (title, widget) died on the first library switch. The widget now re-fetches ITSELF: a /dashboard/week-widget fragment (same resolveLibrary family-lens rules as the page) is pulled by htmx whenever the switcher dispatches week-refresh — carrying the current #library-select value — so the widget both survives switches and RE-SCOPES to the switched library. Initial SSR renders the same scope the sections use. ThisWeek (and the streak calendar) gained an optional library filter. Tests: TestReadingStatsWeekLibraryScope — per-library totals, scoped streaks, invisible-library isolation, malformed id 400. Full suite PASS.
100 lines
3.1 KiB
Go
100 lines
3.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
"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)
|
|
}
|
|
|
|
// 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)
|
|
}
|