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:
1 parent
8de8883acf
commit
6457fbaf72
28 files changed
+3061
-814
No files matched your search
@@ -597,9 +597,96 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
)
|
||||
}
|
||||
|
||||
// Reading-history capture: threading stats off the sync stream. Every
|
||||
// successful progress write yields one reading_history session row —
|
||||
// the span since the PREVIOUS write instant (bounds are one or the
|
||||
// other: the row is recorded regardless, time attribution is
|
||||
// conservative). Client cadence drives the granularity (web/app
|
||||
// debounced pushes, KOReader page-count pushes); nothing second-level
|
||||
// is promised and nothing client-side changes.
|
||||
s.captureReadingSession(ctx, req, existing, hasExisting, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// maxAttributedSessionSeconds caps a derived session: pushes farther
|
||||
// apart than this carry unattributable idle time (overnight suspend →
|
||||
// resume, offline queue flushes) and are kept as zero-second markers
|
||||
// instead of inflating totals.
|
||||
const maxAttributedSessionSeconds = int32(2 * 60 * 60)
|
||||
|
||||
// captureReadingSession records one reading_history row for this push.
|
||||
// Never fails the sync: capture errors are logged and swallowed.
|
||||
func (s *ProgressService) captureReadingSession(ctx context.Context, req SaveProgressRequest, existing database.ReadingProgress, hasExisting bool, saved database.ReadingProgress) {
|
||||
var seconds int32
|
||||
sessionEnd := saved.LastReadAt
|
||||
sessionStart := sessionEnd // first-contact writes start at the push itself
|
||||
if sessionEnd.Valid && hasExisting && existing.LastReadAt.Valid {
|
||||
sessionStart = existing.LastReadAt
|
||||
if elapsed := int32(sessionEnd.Time.Sub(existing.LastReadAt.Time).Seconds()); elapsed > 0 {
|
||||
if elapsed <= maxAttributedSessionSeconds {
|
||||
seconds = elapsed // verified-continuous span
|
||||
} // else: idle gap — marker row, zero time
|
||||
}
|
||||
}
|
||||
|
||||
// Pages read: only when both sides carry a real page index (0 means
|
||||
// unknown for several clients, e.g. LOC-less KOReader pushes).
|
||||
var pages pgtype.Int4
|
||||
if hasExisting && existing.CurrentPage.Valid && existing.CurrentPage.Int32 > 0 &&
|
||||
saved.CurrentPage.Valid && saved.CurrentPage.Int32 > 0 {
|
||||
if deltaPages := saved.CurrentPage.Int32 - existing.CurrentPage.Int32; deltaPages > 0 && deltaPages <= 5000 {
|
||||
pages = pgtype.Int4{Int32: deltaPages, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
// device_id references real devices (FK). The web/app progress paths
|
||||
// historically stuff the USER id into DeviceID (it only fed a
|
||||
// broadcast label before stats existed) — probe before writing,
|
||||
// otherwise capture a device-less session and keep the label in
|
||||
// metadata only.
|
||||
var deviceID pgtype.UUID
|
||||
if req.DeviceID.Valid {
|
||||
exists, probeErr := s.db.DeviceExistsForUser(ctx, database.DeviceExistsForUserParams{
|
||||
DeviceID: req.DeviceID,
|
||||
UserID: req.UserID,
|
||||
})
|
||||
if probeErr == nil && exists {
|
||||
deviceID = req.DeviceID
|
||||
}
|
||||
}
|
||||
metadata := map[string]string{"source": req.Source}
|
||||
|
||||
_, err := s.db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DeviceID: deviceID,
|
||||
ProgressPercentage: saved.Percentage,
|
||||
ReadingSessionStart: sessionStart,
|
||||
ReadingSessionEnd: sessionEnd,
|
||||
PagesRead: pages,
|
||||
TimeSpentSeconds: pgtype.Int4{Int32: seconds, Valid: true},
|
||||
DeviceMetadata: mustJSON(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("reading-history capture failed (non-fatal) for %s: %v", uniqueKeyString(req), err)
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueKeyString(req SaveProgressRequest) string {
|
||||
return uuid.UUID(req.MediaItemID.Bytes).String()
|
||||
}
|
||||
|
||||
// mustJSON marshals metadata or an empty document on failure — capture
|
||||
// must never break on a serialization corner.
|
||||
func mustJSON(v interface{}) []byte {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return []byte(`{}`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
|
||||
data := map[string]interface{}{}
|
||||
if req.Percentage != nil {
|
||||
|
||||
Reference in new issue
Block a user