feat(stats): reading statistics — session capture wired + /stats page + widget + per-book card

The design parked in the handoff (multi-tier stats off reading_history;
bookorbit-style in data, not looks) is in. The real prerequisite work
was data: reading_history existed but NOTHING wrote to it — the whole
stats feature was sitting on an empty table.

- CAPTURE (no client changes, no repo-boundary crossings): every
  successful progress write through SaveProgress (KOReader + app + web
  + Kobo + offline queue — the one chokepoint) now records one
  reading_history session row: span = the interval since the PREVIOUS
  write instant, pages = positive page delta when both sides know the
  page index, marker rows for first contact. Spans over 2h are kept as
  zero-second markers (overnight suspend→resume, offline flushes must
  not inflate totals) — the honest 'sync cadence, not a stopwatch'
  caveat stays. device_id only when a REAL device row matches (the
  web/app progress path historically stuffs the USER id into DeviceID
  — probe instead of trusting it; FK violation would silently drop
  every capture). Source preserved in device_metadata.

- API: /api/stats/summary?range=7|30|90|year (totals, zero-filled
  daily arrays, streaks, most-active day/weekday, top books, library
  breakdown — one call), /api/stats/this-week (widget: rolling 7d vs
  previous + streak), /api/stats/books/:id (lifetime + recent
  sessions). All bucketed in the user's timezone; finished ≡ any push
  ≥0.995; visible-library lens enforced at source. Legacy
  /api/analytics/* kept untouched. Range windows anchor end-of-day to
  the START of today (blasting the clock time into tomorrow resolved
  'today' a calendar day late — found by the streak test).

- /stats page (sidebar Stats between Progress and Devices, previously
  /analytics was an orphan): range selector, five stat cards, two
  Chart.js v4 charts (daily minutes bar, pages/day line — chunked via
  dynamic import so main.js stays untouched), per-library breakdown,
  most-read list, empty-state explainer. /analytics now 301s to /stats.
  The orphaned analytics.templ + analytics.ts removed (their containers
  and the page JSON never matched; the page was astub).

- Dashboard 'This Week' widget (server-rendered, suppressed on service
  failure) and the book detail 'Your Reading Stats' card (lifetime
  totals of the viewer's own sessions) complete the tiers: per-user
  default, per-library breakdown inline, per-book on the page,
  per-system/admin aggregate parked as before.

Tests: statistics_test.go (capture semantics at the chokepoint,
tz/day buckets, zero-fill, streak math, finished detection, range
validation, auth gate, widget, per-book lifetime). Full suite PASS.
This commit is contained in:
John O'Keefe committed 2026-10-14 12:46:38 -04:00
1 parent 8de8883acf
commit 6457fbaf72
28 files changed
+3061 -814

No files matched your search

+16
View File
@@ -16,6 +16,11 @@ type MediaDetail struct {
Collections []database.Collections `json:"collections"`
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
// The viewer's own reading-history card for this book (nil when the
// user has no sessions here); rendered as the "Your Reading Stats"
// section of the book page.
BookStats *BookStatsData `json:"book_stats,omitempty"`
// Conflict data (if exists)
ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"`
@@ -27,3 +32,14 @@ type MediaDetail struct {
// page's "recently deleted" list with restore/permanent-delete actions.
DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"`
}
// BookStatsData is the per-book reading-stats card payload.
type BookStatsData struct {
TotalMinutes int
TotalPages int
SessionCount int
MaxProgress float64 // 0..1
Finished bool
LastSession string
FirstSession string
}
+88
View File
@@ -0,0 +1,88 @@
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.
func (h *StatsHandler) ThisWeek(c *echo.Context) error {
user, ok := mustUser(c)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
week, err := h.statsService.ThisWeek(c.Request().Context(),
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
user.Timezone.String,
)
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)
}