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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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