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
@@ -1467,6 +1467,103 @@ SELECT
|
||||
ORDER BY read_count DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
||||
-- Every stats query scopes to the requesting user's VISIBLE libraries
|
||||
-- (family lens at the source) and single scan of reading_history.
|
||||
|
||||
-- Full session rows for a range (summary math runs in Go: tz buckets,
|
||||
-- streaks, finished-book detection). The bucket/range key is the session
|
||||
-- END instant (the push time; NULL falls back to created_at) — the same
|
||||
-- value the capture stamps every write with.
|
||||
-- name: GetUserReadingSessionsRange :many
|
||||
SELECT
|
||||
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
||||
rh.reading_session_start, rh.reading_session_end,
|
||||
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
||||
mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
ORDER BY rh.created_at ASC;
|
||||
|
||||
-- Per-library aggregation for the same range.
|
||||
-- name: GetUserLibraryBreakdown :many
|
||||
SELECT
|
||||
l.id::uuid AS library_id,
|
||||
l.name,
|
||||
COUNT(*)::int AS session_count,
|
||||
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
JOIN libraries l ON l.id = mi.library_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
GROUP BY l.id, l.name
|
||||
ORDER BY total_seconds DESC;
|
||||
|
||||
-- Most-read books for a range (minutes), for the top list.
|
||||
-- name: GetTopBooksByMinutes :many
|
||||
SELECT
|
||||
mi.id::uuid AS media_item_id,
|
||||
mi.title,
|
||||
mi.author,
|
||||
mi.library_id,
|
||||
mi.cover_image_path,
|
||||
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||
COUNT(*)::int AS session_count,
|
||||
MAX(rh.created_at) AS last_read
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||
ORDER BY total_seconds DESC
|
||||
LIMIT sqlc.arg('limit');
|
||||
|
||||
-- Lifetime session rows for ONE book (the book-detail stats card).
|
||||
-- name: GetBookReadingSessions :many
|
||||
SELECT
|
||||
rh.id, rh.device_id, rh.progress_percentage,
|
||||
rh.reading_session_start, rh.reading_session_end,
|
||||
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at
|
||||
FROM reading_history rh
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND rh.media_item_id = sqlc.arg('media_item_id')
|
||||
ORDER BY rh.created_at ASC;
|
||||
|
||||
-- Distinct ACTIVE days (any captured session) bucketed in the user's
|
||||
-- timezone — the streak calendar. Scoped to a long look-back so the
|
||||
-- "longest streak" and a current streak longer than the selected range
|
||||
-- are both real.
|
||||
-- name: GetUserReadingActiveDays :many
|
||||
SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE sqlc.arg('tz')::text)::date::text AS day
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
ORDER BY day ASC;
|
||||
|
||||
-- Device existence probe for capture: the web/app progress paths
|
||||
-- historically stuff the user id into DeviceID (it only fed a broadcast
|
||||
-- label before stats); a capture row must only reference a REAL device.
|
||||
-- name: DeviceExistsForUser :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM devices
|
||||
WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id')
|
||||
) AS has_device;
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- DEVICE MANAGEMENT & AUTH
|
||||
-- ============================================
|
||||
|
||||
Reference in new issue
Block a user