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

+52 -3
View File
@@ -473,8 +473,30 @@ func registerFrontendRoutes(cfg *Config) {
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
allSectionsData := handlers.BuildSections(allSections, libraryID)
// “This week” widget: the user's rolling 7-day reading pulse, from
// the same session seed the /stats page uses. Suppressed when the
// service fails (dashboard always renders).
var week *templates.WeekStatsData
if cfg.StatisticsService != nil {
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone)
if weekErr == nil {
week = &templates.WeekStatsData{
WeekMinutes: weekSvc.WeekMinutes,
WeekPages: weekSvc.WeekPages,
WeekSessions: weekSvc.WeekSessions,
WeekBooks: weekSvc.WeekBooks,
PrevMinutes: weekSvc.PrevMinutes,
ActiveToday: weekSvc.ActiveToday,
CurrentStreak: weekSvc.CurrentStreak,
}
} else {
log.Printf("ThisWeek widget failed: %v", weekErr)
}
}
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -948,14 +970,22 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
})
// Analytics page
// Analytics page → /stats (the rebuilt reading-statistics page;
// nobody bookmarks orphan pages twice).
frontendProtected.GET("/analytics", func(c *echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/stats")
})
// Reading statistics page — per-user by default, with library
// breakdown; the per-book tier lives on the book detail page and the
// dashboard carries the "this week" widget.
frontendProtected.GET("/stats", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
var buf bytes.Buffer
err = templates.Analytics(user).Render(c.Request().Context(), &buf)
err = templates.Stats(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -1577,6 +1607,24 @@ func registerFrontendRoutes(cfg *Config) {
UserID: pgUserID,
})
// Per-book reading stats (the viewer's own history card) — nil
// keeps the card off the page for untouched books / other users.
var bookStats *handlers.BookStatsData
if cfg.StatisticsService != nil {
if svcStats, serr := cfg.StatisticsService.BookStats(c.Request().Context(),
pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 {
bookStats = &handlers.BookStatsData{
TotalMinutes: svcStats.TotalMinutes,
TotalPages: svcStats.TotalPages,
SessionCount: svcStats.SessionCount,
MaxProgress: svcStats.MaxProgress,
Finished: svcStats.Finished,
FirstSession: svcStats.FirstSession,
LastSession: svcStats.LastSession,
}
}
}
// Assemble response (no field duplication!)
detail := handlers.MediaDetail{
MediaItems: mediaItem, // Embedded - ALL fields available
@@ -1584,6 +1632,7 @@ func registerFrontendRoutes(cfg *Config) {
Rating: rating,
Collections: collections,
ReadingProgress: progress,
BookStats: bookStats,
ActiveConflict: activeConflict,
NotesCount: len(notes),
HighlightsCount: len(highlights),