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

+23
View File
@@ -153,6 +153,10 @@ type Querier interface {
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
// 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.
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
// Find content-duplicate groups (same library + SHA-256, more than one row)
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
@@ -165,6 +169,8 @@ type Querier interface {
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
// Lifetime session rows for ONE book (the book-detail stats card).
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
@@ -338,6 +344,8 @@ type Querier interface {
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
GetSystemTimezone(ctx context.Context) (string, error)
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
// Most-read books for a range (minutes), for the top list.
GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error)
// Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
// Get unlinked book by ContentId
@@ -354,12 +362,27 @@ type Querier interface {
// Get user device usage statistics
GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error)
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
// Per-library aggregation for the same range.
GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error)
GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error)
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error)
// 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.
GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error)
// Analytics queries
// Get user reading history for analytics
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
// 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.
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
+361
View File
@@ -2389,6 +2389,28 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser
return err
}
const DeviceExistsForUser = `-- name: DeviceExistsForUser :one
SELECT EXISTS(
SELECT 1 FROM devices
WHERE id = $1 AND user_id = $2
) AS has_device
`
type DeviceExistsForUserParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
// 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.
func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error) {
row := q.db.QueryRow(ctx, DeviceExistsForUser, arg.DeviceID, arg.UserID)
var has_device bool
err := row.Scan(&has_device)
return has_device, err
}
const FindHashConflictGroups = `-- name: FindHashConflictGroups :many
SELECT library_id, file_sha256, COUNT(*) AS dup_count
FROM media_items
@@ -2726,6 +2748,65 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
return items, nil
}
const GetBookReadingSessions = `-- 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 = $1
AND rh.media_item_id = $2
ORDER BY rh.created_at ASC
`
type GetBookReadingSessionsParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
}
type GetBookReadingSessionsRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
}
// Lifetime session rows for ONE book (the book-detail stats card).
func (q *Queries) GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error) {
rows, err := q.db.Query(ctx, GetBookReadingSessions, arg.UserID, arg.MediaItemID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetBookReadingSessionsRow{}
for rows.Next() {
var i GetBookReadingSessionsRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ProgressPercentage,
&i.ReadingSessionStart,
&i.ReadingSessionEnd,
&i.PagesRead,
&i.TimeSpentSeconds,
&i.DeviceMetadata,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetBooksByTag = `-- name: GetBooksByTag :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
@@ -7674,6 +7755,85 @@ func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTo
return items, nil
}
const GetTopBooksByMinutes = `-- 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 = $1
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path
ORDER BY total_seconds DESC
LIMIT $5
`
type GetTopBooksByMinutesParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
Limit int32 `db:"limit" json:"limit"`
}
type GetTopBooksByMinutesRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
TotalSeconds int32 `db:"total_seconds" json:"total_seconds"`
TotalPages int32 `db:"total_pages" json:"total_pages"`
SessionCount int32 `db:"session_count" json:"session_count"`
LastRead interface{} `db:"last_read" json:"last_read"`
}
// Most-read books for a range (minutes), for the top list.
func (q *Queries) GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error) {
rows, err := q.db.Query(ctx, GetTopBooksByMinutes,
arg.UserID,
arg.StartTime,
arg.EndTime,
arg.VisibleLibraryIds,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetTopBooksByMinutesRow{}
for rows.Next() {
var i GetTopBooksByMinutesRow
if err := rows.Scan(
&i.MediaItemID,
&i.Title,
&i.Author,
&i.LibraryID,
&i.CoverImagePath,
&i.TotalSeconds,
&i.TotalPages,
&i.SessionCount,
&i.LastRead,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUniversalProgress = `-- name: GetUniversalProgress :one
SELECT
rp.id,
@@ -8191,6 +8351,74 @@ func (q *Queries) GetUserForLogin(ctx context.Context, email string) (GetUserFor
return i, err
}
const GetUserLibraryBreakdown = `-- 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 = $1
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
GROUP BY l.id, l.name
ORDER BY total_seconds DESC
`
type GetUserLibraryBreakdownParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
type GetUserLibraryBreakdownRow struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Name string `db:"name" json:"name"`
SessionCount int32 `db:"session_count" json:"session_count"`
TotalSeconds int32 `db:"total_seconds" json:"total_seconds"`
TotalPages int32 `db:"total_pages" json:"total_pages"`
BookCount int32 `db:"book_count" json:"book_count"`
}
// Per-library aggregation for the same range.
func (q *Queries) GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error) {
rows, err := q.db.Query(ctx, GetUserLibraryBreakdown,
arg.UserID,
arg.StartTime,
arg.EndTime,
arg.VisibleLibraryIds,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUserLibraryBreakdownRow{}
for rows.Next() {
var i GetUserLibraryBreakdownRow
if err := rows.Scan(
&i.LibraryID,
&i.Name,
&i.SessionCount,
&i.TotalSeconds,
&i.TotalPages,
&i.BookCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserMediaItemsForSync = `-- name: GetUserMediaItemsForSync :many
SELECT
mi.id,
@@ -8358,6 +8586,52 @@ func (q *Queries) GetUserProgressForBooks(ctx context.Context, arg GetUserProgre
return items, nil
}
const GetUserReadingActiveDays = `-- name: GetUserReadingActiveDays :many
SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE $1::text)::date::text AS day
FROM reading_history rh
JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = $2
AND COALESCE(rh.reading_session_end, rh.created_at) >= $3
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
ORDER BY day ASC
`
type GetUserReadingActiveDaysParams struct {
Tz string `db:"tz" json:"tz"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
// 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.
func (q *Queries) GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error) {
rows, err := q.db.Query(ctx, GetUserReadingActiveDays,
arg.Tz,
arg.UserID,
arg.StartTime,
arg.VisibleLibraryIds,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var day string
if err := rows.Scan(&day); err != nil {
return nil, err
}
items = append(items, day)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserReadingHistory = `-- name: GetUserReadingHistory :many
SELECT
@@ -8447,6 +8721,93 @@ func (q *Queries) GetUserReadingHistory(ctx context.Context, arg GetUserReadingH
return items, nil
}
const GetUserReadingSessionsRange = `-- 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 = $1
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
ORDER BY rh.created_at ASC
`
type GetUserReadingSessionsRangeParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
type GetUserReadingSessionsRangeRow struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
}
// 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.
func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error) {
rows, err := q.db.Query(ctx, GetUserReadingSessionsRange,
arg.UserID,
arg.StartTime,
arg.EndTime,
arg.VisibleLibraryIds,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUserReadingSessionsRangeRow{}
for rows.Next() {
var i GetUserReadingSessionsRangeRow
if err := rows.Scan(
&i.ID,
&i.MediaItemID,
&i.DeviceID,
&i.ProgressPercentage,
&i.ReadingSessionStart,
&i.ReadingSessionEnd,
&i.PagesRead,
&i.TimeSpentSeconds,
&i.DeviceMetadata,
&i.CreatedAt,
&i.Title,
&i.Author,
&i.LibraryID,
&i.CoverImagePath,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description,
COALESCE(lv.is_visible, true) as is_visible
+97
View File
@@ -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
-- ============================================
+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)
}
+10 -1
View File
@@ -8,9 +8,18 @@ func registerAnalyticsRoutes(cfg *Config) {
protected := e.Group("/api", jwtMiddleware)
// Analytics routes
// Analytics routes (legacy shape; kept for existing API consumers —
// the web UI moved to /stats)
analytics := protected.Group("/analytics")
analytics.GET("/reading-stats", cfg.AnalyticsHandler.GetReadingStats)
analytics.GET("/device-usage", cfg.AnalyticsHandler.GetDeviceUsage)
analytics.GET("/popular-books", cfg.AnalyticsHandler.GetPopularBooks)
// Reading-statistics API (/stats page + dashboard widget + per-book
// card). Same session seed, service-backed aggregates, user-timezone
// bucketing. The legacy /analytics page redirects to /stats.
stats := protected.Group("/stats")
stats.GET("/summary", cfg.StatsHandler.Summary)
stats.GET("/this-week", cfg.StatsHandler.ThisWeek)
stats.GET("/books/:id", cfg.StatsHandler.BookStats)
}
+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),
+2
View File
@@ -52,6 +52,8 @@ type Config struct {
WSHandler *handlers.WSHandler
ConflictHandler *handlers.ConflictHandler
AnalyticsHandler *handlers.AnalyticsHandler
StatsHandler *handlers.StatsHandler
StatisticsService *services.StatisticsService
QueueHandler *handlers.QueueHandler
CollectionHandler *handlers.CollectionHandler
Worker *services.Worker
+9 -9
View File
@@ -340,9 +340,9 @@ func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID,
}
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
if err != nil {
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
}
@@ -374,9 +374,9 @@ func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uui
}
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
}
@@ -410,9 +410,9 @@ func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UU
}
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
}
+619
View File
@@ -0,0 +1,619 @@
package services
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// StatisticsService aggregates reading_history sessions into the
// reading-stats surfaces: the /stats page, its per-book card, and the
// dashboard "this week" widget. All day bucketing happens in the
// REQUESTING user's timezone — every calendar concept here is per-user
// (server convention; "today" is the user's calendar, never UTC).
type StatisticsService struct {
db *database.Queries
}
func NewStatisticsService(db *database.Queries) *StatisticsService {
return &StatisticsService{db: db}
}
// finishedThreshold mirrors what the readers push at the end of a book:
// 0.995 leaves room for a TOC/colophon page pushing just under 100%
// (percentage is a 0..1 float everywhere on the server).
const finishedThreshold = 0.995
// streakLookbackDays bounds the streak calendar (both streaks computed
// over that recent window — "in record").
const streakLookbackDays = 730
// topBooksLimit is the "most read" list length on the /stats page.
const topBooksLimit = 5
// StatsRange is an inclusive calendar range in a user's timezone.
type StatsRange struct {
Start time.Time // first instant of the first day
End time.Time // last nanosecond of the last day
Days int
}
// ResolveStatsRange maps the UI's selector ("7", "30", "90", "year") to
// an inclusive window ending today in the user's timezone.
func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
loc := utils.UserLocation(tz)
now := time.Now().In(loc)
// Anchor the end-of-day to the START of today (clock time stripped):
// today.AddDate(0,0,1) minus a nanosecond would carry this instant's
// clock into tomorrow and resolve "today" a calendar day late.
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
end := todayStart.AddDate(0, 0, 1).Add(-time.Nanosecond)
var days int
switch rangeKey {
case "", "7":
days = 7
case "30":
days = 30
case "90":
days = 90
case "year":
// Calendar year to date (Jan 1 → today).
jan1 := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc)
days = int(now.Sub(jan1).Hours()/24.0) + 1
if days < 1 {
days = 1
}
default:
return StatsRange{}, fmt.Errorf("invalid range: %s (use 7, 30, 90 or year)", rangeKey)
}
start := todayStart.AddDate(0, 0, -(days - 1))
return StatsRange{Start: start, End: end, Days: days}, nil
}
type DailyStats struct {
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
Minutes int `json:"minutes"`
Pages int `json:"pages"`
Sessions int `json:"sessions"`
}
type StreakStats struct {
// Current counts consecutive active days ending at today; when today
// has no session yet, the streak that ends yesterday still counts.
Current int `json:"current"`
Longest int `json:"longest"` // longest active streak in record
ActiveToday bool `json:"active_today"`
}
type TopBookStats struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
Minutes int `json:"minutes"`
Pages int `json:"pages"`
Sessions int `json:"sessions"`
LastRead string `json:"last_read"`
}
type LibraryStats struct {
LibraryID string `json:"library_id"`
Name string `json:"name"`
Minutes int `json:"minutes"`
Pages int `json:"pages"`
Sessions int `json:"sessions"`
Books int `json:"books"`
}
type UserSummaryStats struct {
Range string `json:"range"`
Days int `json:"days"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
TotalMinutes int `json:"total_minutes"`
TotalPages int `json:"total_pages"`
SessionCount int `json:"session_count"`
BooksActive int `json:"books_with_activity"`
BooksFinished int `json:"books_finished"`
CompletionRate float64 `json:"completion_rate"` // finished / books with activity (0 when none)
AvgSessionMin float64 `json:"average_session_minutes"`
Daily []DailyStats `json:"daily"` // zero-filled across the range
Streak StreakStats `json:"streak"`
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
MostActiveDay string `json:"most_active_day"` // date with the most minutes
MostActiveMinutes int `json:"most_active_day_minutes"`
TopBooks []TopBookStats `json:"top_books"`
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
}
type WeekSummaryStats struct {
WeekMinutes int `json:"week_minutes"`
WeekPages int `json:"week_pages"`
WeekSessions int `json:"week_sessions"`
WeekBooks int `json:"week_books"`
PrevMinutes int `json:"prev_week_minutes"`
PrevPages int `json:"prev_week_pages"`
PrevSessions int `json:"prev_week_sessions"`
ActiveToday bool `json:"active_today"`
CurrentStreak int `json:"current_streak"`
}
type BookStats struct {
MediaItemID string `json:"media_item_id"`
TotalMinutes int `json:"total_minutes"`
TotalPages int `json:"total_pages"`
SessionCount int `json:"session_count"`
MaxProgress float64 `json:"max_progress"`
Finished bool `json:"finished"`
FirstSession string `json:"first_session"`
LastSession string `json:"last_session"`
RecentSessions []SessionSummary `json:"recent_sessions"` // newest first, capped
}
type SessionSummary struct {
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
Minutes int `json:"minutes"`
Pages int `json:"pages"` // 0 when unknown (no page info on the pushes)
Progress float64 `json:"progress"`
Source string `json:"source"`
}
// userVisibleLibraryIDs loads the family-lens set for this user (empty →
// no visible-library filter, i.e. everything is visible by default).
func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := s.db.GetUserVisibleLibraries(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to check library access: %w", err)
}
ids := make([]pgtype.UUID, 0, len(rows))
for _, lib := range rows {
if lib.ID.Valid {
ids = append(ids, lib.ID)
}
}
return ids, nil
}
// visibleOrNil maps an empty visible set to a NULL filter argument.
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
if len(ids) == 0 {
return nil
}
return ids
}
// UserSummary builds the /stats page payload for a range: totals,
// zero-filled daily arrays, streaks, most-active day, top books, and
// the per-library breakdown — one call for the page.
func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, tz, rangeKey string) (UserSummaryStats, error) {
statsRange, err := ResolveStatsRange(tz, rangeKey)
if err != nil {
return UserSummaryStats{}, err
}
loc := utils.UserLocation(tz)
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
if err != nil {
return UserSummaryStats{}, err
}
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err != nil {
return UserSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
}
summary := UserSummaryStats{
Range: rangeKey,
Days: statsRange.Days,
StartDate: statsRange.Start.Format("2006-01-02"),
EndDate: statsRange.End.Format("2006-01-02"),
Daily: make([]DailyStats, 0, statsRange.Days),
TopBooks: []TopBookStats{},
LibraryBreakdown: []LibraryStats{},
}
// Zero-filled calendar (inclusive of today).
dailyMap := make(map[string]*DailyStats, statsRange.Days)
for i := 0; i < statsRange.Days; i++ {
day := statsRange.Start.AddDate(0, 0, i)
entry := DailyStats{Date: day.Format("2006-01-02")}
summary.Daily = append(summary.Daily, entry)
dailyMap[entry.Date] = &summary.Daily[len(summary.Daily)-1]
}
bookActivity := make(map[string]bool, len(sessions))
finishedSet := make(map[string]bool)
var totalSessionSeconds int64
weekdayMinutes := make(map[time.Weekday]int)
for _, sess := range sessions {
// The bucket key matches the SQL range filter: the session END
// instant (what the capture stamps; NULL falls back to
// created_at) — not the row's created_at.
ts := sess.CreatedAt
if sess.ReadingSessionEnd.Valid {
ts = sess.ReadingSessionEnd
}
key := dayKey(ts, loc)
entry, ok := dailyMap[key]
if !ok {
continue // defensive: outside the inclusive window
}
if sess.TimeSpentSeconds.Valid {
seconds := int64(sess.TimeSpentSeconds.Int32)
totalSessionSeconds += seconds
summary.TotalMinutes += int(seconds / 60)
entry.Minutes += int(seconds / 60)
}
if sess.PagesRead.Valid {
summary.TotalPages += int(sess.PagesRead.Int32)
entry.Pages += int(sess.PagesRead.Int32)
}
entry.Sessions++
summary.SessionCount++
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
if err == nil {
key := mediaUUID.String()
bookActivity[key] = true
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold {
finishedSet[key] = true
}
}
localTime := ts.Time.In(loc)
if sess.TimeSpentSeconds.Valid {
weekdayMinutes[localTime.Weekday()] += int(sess.TimeSpentSeconds.Int32) / 60
}
}
summary.BooksActive = len(bookActivity)
summary.BooksFinished = len(finishedSet)
if summary.BooksActive > 0 {
summary.CompletionRate = float64(summary.BooksFinished) / float64(summary.BooksActive)
}
if summary.SessionCount > 0 {
summary.AvgSessionMin = float64(totalSessionSeconds) / float64(summary.SessionCount) / 60.0
}
// Most-active weekday by minutes (empty string before any signal).
dayNames := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
bestWeekdayMinutes := -1
var bestWeekday time.Weekday
for wd, mins := range weekdayMinutes {
if mins > bestWeekdayMinutes {
bestWeekdayMinutes = mins
bestWeekday = wd
}
}
if bestWeekdayMinutes > 0 {
summary.MostActiveWeekday = dayNames[bestWeekday]
}
for _, d := range summary.Daily {
if d.Minutes > summary.MostActiveMinutes {
summary.MostActiveMinutes = d.Minutes
summary.MostActiveDay = d.Date
}
}
// Streaks: a dedicated lightweight calendar (~2y look-back) so a
// current streak longer than the selected range is still real.
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
Tz: loc.String(),
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err == nil {
summary.Streak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02"))
} else {
log.Printf("stats: active-days query failed: %v", err)
}
// Top books by minutes in the range.
topRows, err := s.db.GetTopBooksByMinutes(ctx, database.GetTopBooksByMinutesParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
Limit: topBooksLimit,
})
if err == nil {
for _, row := range topRows {
entry := TopBookStats{
Title: row.Title,
Author: row.Author.String,
CoverImagePath: utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath),
Minutes: int(row.TotalSeconds) / 60,
Pages: int(row.TotalPages),
Sessions: int(row.SessionCount),
}
if row.MediaItemID.Valid {
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
}
if row.LastRead != nil {
if t, ok := row.LastRead.(time.Time); ok {
entry.LastRead = utils.FormatTimeInTimezone(t, tz)
}
}
summary.TopBooks = append(summary.TopBooks, entry)
}
}
// Per-library breakdown in the range.
libRows, err := s.db.GetUserLibraryBreakdown(ctx, database.GetUserLibraryBreakdownParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err == nil {
for _, row := range libRows {
entry := LibraryStats{
Name: row.Name,
Minutes: int(row.TotalSeconds) / 60,
Pages: int(row.TotalPages),
Sessions: int(row.SessionCount),
Books: int(row.BookCount),
}
if row.LibraryID.Valid {
entry.LibraryID = uuid.UUID(row.LibraryID.Bytes).String()
}
summary.LibraryBreakdown = append(summary.LibraryBreakdown, entry)
}
}
return summary, nil
}
// ThisWeek builds the dashboard widget: the current rolling 7-day
// window (today inclusive) versus the previous seven days, plus the
// streak context. One scan over 14 days, split in Go.
func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz string) (WeekSummaryStats, error) {
statsRange, err := ResolveStatsRange(tz, "7")
if err != nil {
return WeekSummaryStats{}, err
}
loc := utils.UserLocation(tz)
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
if err != nil {
return WeekSummaryStats{}, err
}
// Scan the 14-day window: [start - 7 days, end].
scanStart := statsRange.Start.AddDate(0, 0, -7)
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: scanStart, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err != nil {
return WeekSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
}
week := map[string]bool{statsRange.Start.Format("2006-01-02"): true}
for i := 0; i < statsRange.Days; i++ {
week[statsRange.Start.AddDate(0, 0, i).Format("2006-01-02")] = true
}
out := WeekSummaryStats{
ActiveToday: week[statsRange.End.Format("2006-01-02")],
CurrentStreak: 0,
}
booksThis, booksPrev := make(map[string]bool), make(map[string]bool)
for _, sess := range sessions {
// Same bucket key as UserSummary: the session END instant.
ts := sess.CreatedAt
if sess.ReadingSessionEnd.Valid {
ts = sess.ReadingSessionEnd
}
key := dayKey(ts, loc)
minutes := 0
if sess.TimeSpentSeconds.Valid {
minutes = int(sess.TimeSpentSeconds.Int32) / 60
}
pages := 0
if sess.PagesRead.Valid {
pages = int(sess.PagesRead.Int32)
}
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
var bookKey string
if err == nil {
bookKey = mediaUUID.String()
}
if week[key] { // current 7 days
out.WeekMinutes += minutes
out.WeekPages += pages
out.WeekSessions++
if bookKey != "" {
booksThis[bookKey] = true
}
} else { // previous 7 days
out.PrevMinutes += minutes
out.PrevPages += pages
out.PrevSessions++
if bookKey != "" {
booksPrev[bookKey] = true
}
}
}
out.WeekBooks = len(booksThis)
_ = booksPrev
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
Tz: loc.String(),
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err == nil {
out.CurrentStreak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).Current
out.ActiveToday = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).ActiveToday
}
return out, nil
}
// BookStats is the per-book card: lifetime totals + the most recent
// sessions (bounded so the detail page never scans unbounded history).
func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) {
sessions, err := s.db.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
UserID: userID,
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
})
if err != nil {
return BookStats{}, fmt.Errorf("failed to load book sessions: %w", err)
}
out := BookStats{
MediaItemID: mediaItemID.String(),
RecentSessions: []SessionSummary{},
}
for i, sess := range sessions {
if sess.TimeSpentSeconds.Valid {
out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60
}
if sess.PagesRead.Valid {
out.TotalPages += int(sess.PagesRead.Int32)
}
out.SessionCount++
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 > out.MaxProgress {
out.MaxProgress = sess.ProgressPercentage.Float64
}
out.Finished = out.Finished || (sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold)
start := ""
if sess.ReadingSessionStart.Valid {
start = utils.FormatTimeInTimezone(sess.ReadingSessionStart.Time, tz)
}
end := ""
if sess.ReadingSessionEnd.Valid {
end = utils.FormatTimeInTimezone(sess.ReadingSessionEnd.Time, tz)
}
if sess.ReadingSessionEnd.Valid {
if i == 0 {
out.FirstSession = end
}
out.LastSession = end // ascending order → last assignment wins
}
if i >= len(sessions)-10 { // the most recent 10 sessions only
summary := SessionSummary{
StartedAt: start,
EndedAt: end,
Minutes: int(sess.TimeSpentSeconds.Int32) / 60,
Source: metadataSource(sess.DeviceMetadata),
}
if sess.PagesRead.Valid {
summary.Pages = int(sess.PagesRead.Int32)
}
if sess.ProgressPercentage.Valid {
summary.Progress = sess.ProgressPercentage.Float64
}
out.RecentSessions = append(out.RecentSessions, summary)
}
}
// Most-recent first for display.
for i, j := 0, len(out.RecentSessions)-1; i < j; i, j = i+1, j-1 {
out.RecentSessions[i], out.RecentSessions[j] = out.RecentSessions[j], out.RecentSessions[i]
}
return out, nil
}
// computeStreaks walks an ascending ISO-date list (the active-day
// calendar in the user's timezone) anchored at TODAY's date.
func computeStreaks(activeDays []string, today string) StreakStats {
activeSet := make(map[string]bool, len(activeDays))
for _, d := range activeDays {
activeSet[d] = true
}
out := StreakStats{ActiveToday: activeSet[today], Current: 0, Longest: 0}
// Current: walk back from today; an inactive TODAY falls through to
// yesterday so a not-yet-read day doesn't erase a live streak.
anchor := today
if !activeSet[anchor] {
if d, err := time.Parse("2006-01-02", today); err == nil {
anchor = d.AddDate(0, 0, -1).Format("2006-01-02")
}
}
for cur := anchor; activeSet[cur]; {
out.Current++
d, err := time.Parse("2006-01-02", cur)
if err != nil {
break
}
cur = d.AddDate(0, 0, -1).Format("2006-01-02")
}
// Longest run in the ascending ordered list.
run := 0
var prev string
for _, d := range activeDays {
if prev != "" && nextDay(prev) == d {
run++
} else {
run = 1
}
if run > out.Longest {
out.Longest = run
}
prev = d
}
return out
}
// nextDay returns the day after an ISO date, preserving the format.
func nextDay(iso string) string {
d, err := time.Parse("2006-01-02", iso)
if err != nil {
return ""
}
return d.AddDate(0, 0, 1).Format("2006-01-02")
}
// dayKey buckets a session instant into the user's calendar date.
func dayKey(ts pgtype.Timestamptz, loc *time.Location) string {
if !ts.Valid {
return ""
}
return ts.Time.In(loc).Format("2006-01-02")
}
// metadataSource extracts the capture's source tag from device_metadata.
func metadataSource(raw []byte) string {
if len(raw) == 0 {
return ""
}
var meta struct {
Source string `json:"source"`
}
if jsonErr := json.Unmarshal(raw, &meta); jsonErr == nil {
return meta.Source
}
return ""
}
+87
View File
@@ -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 {