[add] heavy query caching, [add] wpm leaderboard

This commit is contained in:
Evan Reichard 2023-10-03 16:47:38 -04:00
parent 5cd4e165b0
commit 752741a2eb
11 changed files with 573 additions and 356 deletions

View File

@ -8,7 +8,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
argon2 "github.com/alexedwards/argon2id" argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
@ -130,7 +129,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
"WordsPerMinute": "N/A", "WordsPerMinute": "N/A",
} }
if document.Words != nil && *document.Words != 0 { if document.Words != nil && *document.Words != 0 && document.TotalTimeSeconds != 0 {
statistics["WordsPerMinute"] = (*document.Words / document.Pages * document.ReadPages) / (document.TotalTimeSeconds / 60.0) statistics["WordsPerMinute"] = (*document.Words / document.Pages * document.ReadPages) / (document.TotalTimeSeconds / 60.0)
} }
@ -158,39 +157,17 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
templateVars["Data"] = activity templateVars["Data"] = activity
} else if routeName == "home" { } else if routeName == "home" {
start_time := time.Now()
weekly_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
UserID: userID,
Window: "WEEK",
})
if err != nil {
log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err)
}
log.Debug("GetUserWindowStreaks - WEEK - ", time.Since(start_time))
start_time = time.Now()
daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, userID)
UserID: userID,
Window: "DAY",
})
if err != nil {
log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err)
}
log.Debug("GetUserWindowStreaks - DAY - ", time.Since(start_time))
start_time = time.Now()
database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, userID) database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, userID)
log.Debug("GetDatabaseInfo - ", time.Since(start_time))
start_time = time.Now()
read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, userID) read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, userID)
log.Debug("GetDailyReadStats - ", time.Since(start_time)) wpn_leaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx)
templateVars["Data"] = gin.H{ templateVars["Data"] = gin.H{
"DailyStreak": daily_streak, "Streaks": streaks,
"WeeklyStreak": weekly_streak,
"DatabaseInfo": database_info,
"GraphData": read_graph_data, "GraphData": read_graph_data,
"DatabaseInfo": database_info,
"WPMLeaderboard": wpn_leaderboard,
} }
} else if routeName == "settings" { } else if routeName == "settings" {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID) user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)

View File

@ -277,6 +277,11 @@ func (api *API) addActivities(c *gin.Context) {
return return
} }
// Update Temp Tables
if err := api.DB.CacheTempTables(); err != nil {
log.Warn("[addActivities] CacheTempTables Failure: ", err)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"added": len(rActivity.Activity), "added": len(rActivity.Activity),
}) })

View File

@ -75,7 +75,7 @@ local STATISTICS_ACTIVITY_SINCE_QUERY = [[
JOIN book AS b JOIN book AS b
ON b.id = psd.id_book ON b.id = psd.id_book
WHERE start_time > %d WHERE start_time > %d
ORDER BY start_time ASC LIMIT 1000; ORDER BY start_time ASC LIMIT 5000;
]] ]]
local STATISTICS_BOOK_QUERY = [[ local STATISTICS_BOOK_QUERY = [[
@ -907,7 +907,7 @@ function SyncNinja:getStatisticsActivity(timestamp)
local conn = SQ3.open(statistics_db) local conn = SQ3.open(statistics_db)
local stmt = conn:prepare(string.format(STATISTICS_ACTIVITY_SINCE_QUERY, local stmt = conn:prepare(string.format(STATISTICS_ACTIVITY_SINCE_QUERY,
timestamp)) timestamp))
local rows = stmt:resultset("i", 1000) local rows = stmt:resultset("i", 5000)
conn:close() conn:close()
-- No Results -- No Results

View File

@ -21,6 +21,9 @@ type DBManager struct {
//go:embed schema.sql //go:embed schema.sql
var ddl string var ddl string
//go:embed update_temp_tables.sql
var tsql string
func NewMgr(c *config.Config) *DBManager { func NewMgr(c *config.Config) *DBManager {
// Create Manager // Create Manager
dbm := &DBManager{ dbm := &DBManager{
@ -44,22 +47,23 @@ func NewMgr(c *config.Config) *DBManager {
log.Fatal("Unsupported Database") log.Fatal("Unsupported Database")
} }
// Create Tables
if _, err := dbm.DB.ExecContext(dbm.Ctx, ddl); err != nil {
log.Fatal(err)
}
dbm.Queries = New(dbm.DB) dbm.Queries = New(dbm.DB)
return dbm return dbm
} }
func connectHookSQLite(conn *sqlite.SQLiteConn) error { func (dbm *DBManager) CacheTempTables() error {
if err := conn.RegisterFunc("test_func", func() string { if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil {
return "FOOBAR"
}, false); err != nil {
log.Info("Error Registering Function")
return err return err
} }
return nil return nil
} }
func connectHookSQLite(conn *sqlite.SQLiteConn) error {
// Create Tables
log.Debug("Creating Schema")
if _, err := conn.Exec(ddl, nil); err != nil {
log.Warn("Create Schema Failure: ", err)
}
return nil
}

View File

@ -5,19 +5,19 @@
package database package database
import ( import (
"database/sql"
"time" "time"
) )
type Activity struct { type Activity struct {
ID int64 `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
CreatedAt time.Time `json:"created_at"`
StartTime time.Time `json:"start_time"` StartTime time.Time `json:"start_time"`
Duration int64 `json:"duration"`
Page int64 `json:"page"` Page int64 `json:"page"`
Pages int64 `json:"pages"` Pages int64 `json:"pages"`
CreatedAt time.Time `json:"created_at"` Duration int64 `json:"duration"`
} }
type Device struct { type Device struct {
@ -67,6 +67,20 @@ type DocumentProgress struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type DocumentUserStatistic struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
LastRead string `json:"last_read"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"`
Percentage float64 `json:"percentage"`
Words int64 `json:"words"`
WordsRead int64 `json:"words_read"`
Wpm float64 `json:"wpm"`
}
type Metadatum struct { type Metadatum struct {
ID int64 `json:"id"` ID int64 `json:"id"`
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
@ -80,14 +94,16 @@ type Metadatum struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type RescaledActivity struct { type RawActivity struct {
ID int64 `json:"id"`
UserID string `json:"user_id"`
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
StartTime time.Time `json:"start_time"` StartTime time.Time `json:"start_time"`
Pages int64 `json:"pages"`
Page int64 `json:"page"` Page int64 `json:"page"`
Pages int64 `json:"pages"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
CreatedAt time.Time `json:"created_at"`
} }
type User struct { type User struct {
@ -97,3 +113,50 @@ type User struct {
TimeOffset *string `json:"time_offset"` TimeOffset *string `json:"time_offset"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type UserStreak struct {
UserID string `json:"user_id"`
Window string `json:"window"`
MaxStreak int64 `json:"max_streak"`
MaxStreakStartDate string `json:"max_streak_start_date"`
MaxStreakEndDate string `json:"max_streak_end_date"`
CurrentStreak int64 `json:"current_streak"`
CurrentStreakStartDate string `json:"current_streak_start_date"`
CurrentStreakEndDate string `json:"current_streak_end_date"`
}
type ViewDocumentUserStatistic struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
LastRead time.Time `json:"last_read"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"`
Percentage float64 `json:"percentage"`
Words float64 `json:"words"`
WordsRead interface{} `json:"words_read"`
Wpm int64 `json:"wpm"`
}
type ViewRescaledActivity struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
CreatedAt time.Time `json:"created_at"`
StartTime time.Time `json:"start_time"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
Duration int64 `json:"duration"`
}
type ViewUserStreak struct {
UserID string `json:"user_id"`
Window string `json:"window"`
MaxStreak interface{} `json:"max_streak"`
MaxStreakStartDate interface{} `json:"max_streak_start_date"`
MaxStreakEndDate interface{} `json:"max_streak_end_date"`
CurrentStreak interface{} `json:"current_streak"`
CurrentStreakStartDate interface{} `json:"current_streak_start_date"`
CurrentStreakEndDate interface{} `json:"current_streak_end_date"`
}

View File

@ -135,7 +135,7 @@ AND user_id = $user_id
ORDER BY start_time DESC LIMIT 1; ORDER BY start_time DESC LIMIT 1;
-- name: AddActivity :one -- name: AddActivity :one
INSERT INTO activity ( INSERT INTO raw_activity (
user_id, user_id,
document_id, document_id,
device_id, device_id,
@ -200,7 +200,7 @@ WITH true_progress AS (
-- Derive Percentage of Book -- Derive Percentage of Book
ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage
FROM rescaled_activity FROM activity
WHERE user_id = $user_id WHERE user_id = $user_id
AND document_id = $document_id AND document_id = $document_id
GROUP BY document_id GROUP BY document_id
@ -291,7 +291,7 @@ OFFSET $offset;
-- name: GetActivity :many -- name: GetActivity :many
SELECT SELECT
document_id, document_id,
CAST(DATETIME(activity.start_time, time_offset) AS TEXT) AS start_time, CAST(DATETIME(activity.start_time, users.time_offset) AS TEXT) AS start_time,
title, title,
author, author,
duration, duration,
@ -303,11 +303,12 @@ LEFT JOIN users ON users.id = activity.user_id
WHERE WHERE
activity.user_id = $user_id activity.user_id = $user_id
AND ( AND (
(
CAST($doc_filter AS BOOLEAN) = TRUE CAST($doc_filter AS BOOLEAN) = TRUE
AND document_id = $document_id AND document_id = $document_id
) OR $doc_filter = FALSE
) )
OR $doc_filter = FALSE ORDER BY activity.start_time DESC
ORDER BY start_time DESC
LIMIT $limit LIMIT $limit
OFFSET $offset; OFFSET $offset;
@ -326,7 +327,7 @@ GROUP BY activity.device_id;
SELECT SELECT
COUNT(DISTINCT page) AS pages_read, COUNT(DISTINCT page) AS pages_read,
SUM(duration) AS total_time SUM(duration) AS total_time
FROM rescaled_activity FROM activity
WHERE document_id = $document_id WHERE document_id = $document_id
AND user_id = $user_id AND user_id = $user_id
AND start_time >= $start_time; AND start_time >= $start_time;
@ -334,7 +335,7 @@ AND start_time >= $start_time;
-- name: GetDocumentReadStatsCapped :one -- name: GetDocumentReadStatsCapped :one
WITH capped_stats AS ( WITH capped_stats AS (
SELECT MIN(SUM(duration), CAST($page_duration_cap AS INTEGER)) AS durations SELECT MIN(SUM(duration), CAST($page_duration_cap AS INTEGER)) AS durations
FROM rescaled_activity FROM activity
WHERE document_id = $document_id WHERE document_id = $document_id
AND user_id = $user_id AND user_id = $user_id
AND start_time >= $start_time AND start_time >= $start_time
@ -357,77 +358,9 @@ WITH document_days AS (
SELECT CAST(COUNT(*) AS INTEGER) AS days_read SELECT CAST(COUNT(*) AS INTEGER) AS days_read
FROM document_days; FROM document_days;
-- name: GetUserWindowStreaks :one -- name: GetUserStreaks :many
WITH document_windows AS ( SELECT * FROM user_streaks
SELECT WHERE user_id = $user_id;
CASE
WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window,
time_offset
FROM activity
JOIN users ON users.id = activity.user_id
WHERE user_id = $user_id
AND CAST($window AS TEXT) = CAST($window AS TEXT)
GROUP BY read_window
),
partitions AS (
SELECT
document_windows.*,
row_number() OVER (
PARTITION BY 1 ORDER BY read_window DESC
) AS seqnum
FROM document_windows
),
streaks AS (
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
time_offset
FROM partitions
GROUP BY
CASE
WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day')
WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day')
END,
time_offset
ORDER BY end_date DESC
),
max_streak AS (
SELECT
MAX(streak) AS max_streak,
start_date AS max_streak_start_date,
end_date AS max_streak_end_date
FROM streaks
LIMIT 1
),
current_streak AS (
SELECT
streak AS current_streak,
start_date AS current_streak_start_date,
end_date AS current_streak_end_date
FROM streaks
WHERE CASE
WHEN ?2 = "WEEK" THEN
DATE('now', time_offset, 'weekday 0', '-14 day') = current_streak_end_date
OR DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date
WHEN ?2 = "DAY" THEN
DATE('now', time_offset, '-1 day') = current_streak_end_date
OR DATE('now', time_offset) = current_streak_end_date
END
LIMIT 1
)
SELECT
CAST(IFNULL(max_streak, 0) AS INTEGER) AS max_streak,
CAST(IFNULL(max_streak_start_date, "N/A") AS TEXT) AS max_streak_start_date,
CAST(IFNULL(max_streak_end_date, "N/A") AS TEXT) AS max_streak_end_date,
IFNULL(current_streak, 0) AS current_streak,
CAST(IFNULL(current_streak_start_date, "N/A") AS TEXT) AS current_streak_start_date,
CAST(IFNULL(current_streak_end_date, "N/A") AS TEXT) AS current_streak_end_date
FROM max_streak
LEFT JOIN current_streak ON 1 = 1
LIMIT 1;
-- name: GetDatabaseInfo :one -- name: GetDatabaseInfo :one
SELECT SELECT
@ -468,3 +401,15 @@ FROM last_30_days
LEFT JOIN activity_records ON activity_records.day == last_30_days.date LEFT JOIN activity_records ON activity_records.day == last_30_days.date
ORDER BY date DESC ORDER BY date DESC
LIMIT 30; LIMIT 30;
-- name: GetWPMLeaderboard :many
SELECT
user_id,
CAST(SUM(words_read) AS INTEGER) AS total_words_read,
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
ROUND(CAST(SUM(words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
AS wpm
FROM document_user_statistics
WHERE words_read > 0
GROUP BY user_id
ORDER BY wpm DESC;

View File

@ -13,7 +13,7 @@ import (
) )
const addActivity = `-- name: AddActivity :one const addActivity = `-- name: AddActivity :one
INSERT INTO activity ( INSERT INTO raw_activity (
user_id, user_id,
document_id, document_id,
device_id, device_id,
@ -23,7 +23,7 @@ INSERT INTO activity (
pages pages
) )
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id, user_id, document_id, device_id, start_time, duration, page, pages, created_at RETURNING id, user_id, document_id, device_id, start_time, page, pages, duration, created_at
` `
type AddActivityParams struct { type AddActivityParams struct {
@ -36,7 +36,7 @@ type AddActivityParams struct {
Pages int64 `json:"pages"` Pages int64 `json:"pages"`
} }
func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (Activity, error) { func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (RawActivity, error) {
row := q.db.QueryRowContext(ctx, addActivity, row := q.db.QueryRowContext(ctx, addActivity,
arg.UserID, arg.UserID,
arg.DocumentID, arg.DocumentID,
@ -46,16 +46,16 @@ func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (Activ
arg.Page, arg.Page,
arg.Pages, arg.Pages,
) )
var i Activity var i RawActivity
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.UserID, &i.UserID,
&i.DocumentID, &i.DocumentID,
&i.DeviceID, &i.DeviceID,
&i.StartTime, &i.StartTime,
&i.Duration,
&i.Page, &i.Page,
&i.Pages, &i.Pages,
&i.Duration,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@ -151,7 +151,7 @@ func (q *Queries) DeleteDocument(ctx context.Context, id string) (int64, error)
const getActivity = `-- name: GetActivity :many const getActivity = `-- name: GetActivity :many
SELECT SELECT
document_id, document_id,
CAST(DATETIME(activity.start_time, time_offset) AS TEXT) AS start_time, CAST(DATETIME(activity.start_time, users.time_offset) AS TEXT) AS start_time,
title, title,
author, author,
duration, duration,
@ -163,11 +163,12 @@ LEFT JOIN users ON users.id = activity.user_id
WHERE WHERE
activity.user_id = ?1 activity.user_id = ?1
AND ( AND (
(
CAST(?2 AS BOOLEAN) = TRUE CAST(?2 AS BOOLEAN) = TRUE
AND document_id = ?3 AND document_id = ?3
) OR ?2 = FALSE
) )
OR ?2 = FALSE ORDER BY activity.start_time DESC
ORDER BY start_time DESC
LIMIT ?5 LIMIT ?5
OFFSET ?4 OFFSET ?4
` `
@ -477,7 +478,7 @@ const getDocumentReadStats = `-- name: GetDocumentReadStats :one
SELECT SELECT
COUNT(DISTINCT page) AS pages_read, COUNT(DISTINCT page) AS pages_read,
SUM(duration) AS total_time SUM(duration) AS total_time
FROM rescaled_activity FROM activity
WHERE document_id = ?1 WHERE document_id = ?1
AND user_id = ?2 AND user_id = ?2
AND start_time >= ?3 AND start_time >= ?3
@ -504,7 +505,7 @@ func (q *Queries) GetDocumentReadStats(ctx context.Context, arg GetDocumentReadS
const getDocumentReadStatsCapped = `-- name: GetDocumentReadStatsCapped :one const getDocumentReadStatsCapped = `-- name: GetDocumentReadStatsCapped :one
WITH capped_stats AS ( WITH capped_stats AS (
SELECT MIN(SUM(duration), CAST(?1 AS INTEGER)) AS durations SELECT MIN(SUM(duration), CAST(?1 AS INTEGER)) AS durations
FROM rescaled_activity FROM activity
WHERE document_id = ?2 WHERE document_id = ?2
AND user_id = ?3 AND user_id = ?3
AND start_time >= ?4 AND start_time >= ?4
@ -554,7 +555,7 @@ WITH true_progress AS (
-- Derive Percentage of Book -- Derive Percentage of Book
ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage
FROM rescaled_activity FROM activity
WHERE user_id = ?1 WHERE user_id = ?1
AND document_id = ?2 AND document_id = ?2
GROUP BY document_id GROUP BY document_id
@ -978,105 +979,41 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
return i, err return i, err
} }
const getUserWindowStreaks = `-- name: GetUserWindowStreaks :one const getUserStreaks = `-- name: GetUserStreaks :many
WITH document_windows AS ( SELECT user_id, "window", max_streak, max_streak_start_date, max_streak_end_date, current_streak, current_streak_start_date, current_streak_end_date FROM user_streaks
SELECT WHERE user_id = ?1
CASE
WHEN ?2 = "WEEK" THEN DATE(start_time, time_offset, 'weekday 0', '-7 day')
WHEN ?2 = "DAY" THEN DATE(start_time, time_offset)
END AS read_window,
time_offset
FROM activity
JOIN users ON users.id = activity.user_id
WHERE user_id = ?1
AND CAST(?2 AS TEXT) = CAST(?2 AS TEXT)
GROUP BY read_window
),
partitions AS (
SELECT
document_windows.read_window, document_windows.time_offset,
row_number() OVER (
PARTITION BY 1 ORDER BY read_window DESC
) AS seqnum
FROM document_windows
),
streaks AS (
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
time_offset
FROM partitions
GROUP BY
CASE
WHEN ?2 = "DAY" THEN DATE(read_window, '+' || seqnum || ' day')
WHEN ?2 = "WEEK" THEN DATE(read_window, '+' || (seqnum * 7) || ' day')
END,
time_offset
ORDER BY end_date DESC
),
max_streak AS (
SELECT
MAX(streak) AS max_streak,
start_date AS max_streak_start_date,
end_date AS max_streak_end_date
FROM streaks
LIMIT 1
),
current_streak AS (
SELECT
streak AS current_streak,
start_date AS current_streak_start_date,
end_date AS current_streak_end_date
FROM streaks
WHERE CASE
WHEN ?2 = "WEEK" THEN
DATE('now', time_offset, 'weekday 0', '-14 day') = current_streak_end_date
OR DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date
WHEN ?2 = "DAY" THEN
DATE('now', time_offset, '-1 day') = current_streak_end_date
OR DATE('now', time_offset) = current_streak_end_date
END
LIMIT 1
)
SELECT
CAST(IFNULL(max_streak, 0) AS INTEGER) AS max_streak,
CAST(IFNULL(max_streak_start_date, "N/A") AS TEXT) AS max_streak_start_date,
CAST(IFNULL(max_streak_end_date, "N/A") AS TEXT) AS max_streak_end_date,
IFNULL(current_streak, 0) AS current_streak,
CAST(IFNULL(current_streak_start_date, "N/A") AS TEXT) AS current_streak_start_date,
CAST(IFNULL(current_streak_end_date, "N/A") AS TEXT) AS current_streak_end_date
FROM max_streak
LEFT JOIN current_streak ON 1 = 1
LIMIT 1
` `
type GetUserWindowStreaksParams struct { func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStreak, error) {
UserID string `json:"user_id"` rows, err := q.db.QueryContext(ctx, getUserStreaks, userID)
Window string `json:"window"` if err != nil {
} return nil, err
}
type GetUserWindowStreaksRow struct { defer rows.Close()
MaxStreak int64 `json:"max_streak"` var items []UserStreak
MaxStreakStartDate string `json:"max_streak_start_date"` for rows.Next() {
MaxStreakEndDate string `json:"max_streak_end_date"` var i UserStreak
CurrentStreak interface{} `json:"current_streak"` if err := rows.Scan(
CurrentStreakStartDate string `json:"current_streak_start_date"` &i.UserID,
CurrentStreakEndDate string `json:"current_streak_end_date"` &i.Window,
}
func (q *Queries) GetUserWindowStreaks(ctx context.Context, arg GetUserWindowStreaksParams) (GetUserWindowStreaksRow, error) {
row := q.db.QueryRowContext(ctx, getUserWindowStreaks, arg.UserID, arg.Window)
var i GetUserWindowStreaksRow
err := row.Scan(
&i.MaxStreak, &i.MaxStreak,
&i.MaxStreakStartDate, &i.MaxStreakStartDate,
&i.MaxStreakEndDate, &i.MaxStreakEndDate,
&i.CurrentStreak, &i.CurrentStreak,
&i.CurrentStreakStartDate, &i.CurrentStreakStartDate,
&i.CurrentStreakEndDate, &i.CurrentStreakEndDate,
) ); err != nil {
return i, err return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
} }
const getUsers = `-- name: GetUsers :many const getUsers = `-- name: GetUsers :many
@ -1129,6 +1066,54 @@ func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, err
return items, nil return items, nil
} }
const getWPMLeaderboard = `-- name: GetWPMLeaderboard :many
SELECT
user_id,
CAST(SUM(words_read) AS INTEGER) AS total_words_read,
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
ROUND(CAST(SUM(words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
AS wpm
FROM document_user_statistics
WHERE words_read > 0
GROUP BY user_id
ORDER BY wpm DESC
`
type GetWPMLeaderboardRow struct {
UserID string `json:"user_id"`
TotalWordsRead int64 `json:"total_words_read"`
TotalSeconds int64 `json:"total_seconds"`
Wpm float64 `json:"wpm"`
}
func (q *Queries) GetWPMLeaderboard(ctx context.Context) ([]GetWPMLeaderboardRow, error) {
rows, err := q.db.QueryContext(ctx, getWPMLeaderboard)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetWPMLeaderboardRow
for rows.Next() {
var i GetWPMLeaderboardRow
if err := rows.Scan(
&i.UserID,
&i.TotalWordsRead,
&i.TotalSeconds,
&i.Wpm,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWantedDocuments = `-- name: GetWantedDocuments :many const getWantedDocuments = `-- name: GetWantedDocuments :many
SELECT SELECT
CAST(value AS TEXT) AS id, CAST(value AS TEXT) AS id,

View File

@ -1,6 +1,10 @@
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
---------------------------------------------------------------
------------------------ Normal Tables ------------------------
---------------------------------------------------------------
-- Authentication -- Authentication
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
@ -101,17 +105,17 @@ CREATE TABLE IF NOT EXISTS document_progress (
PRIMARY KEY (user_id, document_id, device_id) PRIMARY KEY (user_id, document_id, device_id)
); );
-- Read Activity -- Raw Read Activity
CREATE TABLE IF NOT EXISTS activity ( CREATE TABLE IF NOT EXISTS raw_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
document_id TEXT NOT NULL, document_id TEXT NOT NULL,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
start_time DATETIME NOT NULL, start_time DATETIME NOT NULL,
duration INTEGER NOT NULL,
page INTEGER NOT NULL, page INTEGER NOT NULL,
pages INTEGER NOT NULL, pages INTEGER NOT NULL,
duration INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id), FOREIGN KEY (user_id) REFERENCES users (id),
@ -119,23 +123,72 @@ CREATE TABLE IF NOT EXISTS activity (
FOREIGN KEY (device_id) REFERENCES devices (id) FOREIGN KEY (device_id) REFERENCES devices (id)
); );
-- Indexes ---------------------------------------------------------------
CREATE INDEX IF NOT EXISTS activity_start_time ON activity (start_time); ----------------------- Temporary Tables ----------------------
CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity ( ---------------------------------------------------------------
-- Temporary Activity Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS activity (
user_id TEXT NOT NULL,
document_id TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
start_time DATETIME NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
duration INTEGER NOT NULL
);
-- Temporary User Streaks Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
user_id TEXT NOT NULL,
window TEXT NOT NULL,
max_streak INTEGER NOT NULL,
max_streak_start_date TEXT NOT NULL,
max_streak_end_date TEXT NOT NULL,
current_streak INTEGER NOT NULL,
current_streak_start_date TEXT NOT NULL,
current_streak_end_date TEXT NOT NULL
);
CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
document_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read TEXT NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
total_time_seconds INTEGER NOT NULL,
read_pages INTEGER NOT NULL,
percentage REAL NOT NULL,
words INTEGER NOT NULL,
words_read INTEGER NOT NULL,
wpm REAL NOT NULL
);
---------------------------------------------------------------
--------------------------- Indexes ---------------------------
---------------------------------------------------------------
CREATE INDEX IF NOT EXISTS temp.activity_start_time ON activity (start_time);
CREATE INDEX IF NOT EXISTS temp.activity_user_id ON activity (user_id);
CREATE INDEX IF NOT EXISTS temp.activity_user_id_document_id ON activity (
user_id, user_id,
document_id document_id
); );
-- Update Trigger ---------------------------------------------------------------
CREATE TRIGGER IF NOT EXISTS update_documents_updated_at ---------------------------- Views ----------------------------
BEFORE UPDATE ON documents BEGIN ---------------------------------------------------------------
UPDATE documents
SET updated_at = CURRENT_TIMESTAMP
WHERE id = old.id;
END;
-- Rescaled Activity View (Adapted from KOReader) --------------------------------
CREATE VIEW IF NOT EXISTS rescaled_activity AS ------- Rescaled Activity ------
--------------------------------
CREATE VIEW IF NOT EXISTS view_rescaled_activity AS
WITH RECURSIVE nums (idx) AS ( WITH RECURSIVE nums (idx) AS (
SELECT 1 AS idx SELECT 1 AS idx
@ -150,7 +203,7 @@ current_pages AS (
document_id, document_id,
user_id, user_id,
pages pages
FROM activity FROM raw_activity
GROUP BY document_id, user_id GROUP BY document_id, user_id
HAVING MAX(start_time) HAVING MAX(start_time)
ORDER BY start_time DESC ORDER BY start_time DESC
@ -158,33 +211,33 @@ current_pages AS (
intermediate AS ( intermediate AS (
SELECT SELECT
activity.document_id, raw_activity.document_id,
activity.device_id, raw_activity.device_id,
activity.user_id, raw_activity.user_id,
activity.start_time, raw_activity.created_at,
activity.duration, raw_activity.start_time,
activity.page, raw_activity.duration,
raw_activity.page,
current_pages.pages, current_pages.pages,
-- Derive first page -- Derive first page
((activity.page - 1) * current_pages.pages) / activity.pages ((raw_activity.page - 1) * current_pages.pages) / raw_activity.pages
+ 1 AS first_page, + 1 AS first_page,
-- Derive last page -- Derive last page
MAX( MAX(
((activity.page - 1) * current_pages.pages) ((raw_activity.page - 1) * current_pages.pages)
/ activity.pages / raw_activity.pages
+ 1, + 1,
(activity.page * current_pages.pages) / activity.pages (raw_activity.page * current_pages.pages) / raw_activity.pages
) AS last_page ) AS last_page
FROM activity FROM raw_activity
INNER JOIN current_pages ON INNER JOIN current_pages ON
current_pages.document_id = activity.document_id current_pages.document_id = raw_activity.document_id
AND current_pages.user_id = activity.user_id AND current_pages.user_id = raw_activity.user_id
), ),
-- Improves performance
num_limit AS ( num_limit AS (
SELECT * FROM nums SELECT * FROM nums
LIMIT (SELECT MAX(last_page - first_page + 1) FROM intermediate) LIMIT (SELECT MAX(last_page - first_page + 1) FROM intermediate)
@ -192,28 +245,30 @@ num_limit AS (
rescaled_raw AS ( rescaled_raw AS (
SELECT SELECT
document_id, intermediate.document_id,
device_id, intermediate.device_id,
user_id, intermediate.user_id,
start_time, intermediate.created_at,
last_page, intermediate.start_time,
pages, intermediate.last_page,
first_page + num_limit.idx - 1 AS page, intermediate.pages,
duration / ( intermediate.first_page + num_limit.idx - 1 AS page,
last_page - first_page + 1.0 intermediate.duration / (
intermediate.last_page - intermediate.first_page + 1.0
) AS duration ) AS duration
FROM intermediate FROM intermediate
JOIN num_limit ON LEFT JOIN num_limit ON
num_limit.idx <= (last_page - first_page + 1) num_limit.idx <= (intermediate.last_page - intermediate.first_page + 1)
) )
SELECT SELECT
user_id,
document_id, document_id,
device_id, device_id,
user_id, created_at,
start_time, start_time,
pages,
page, page,
pages,
-- Round up if last page (maintains total duration) -- Round up if last page (maintains total duration)
CAST(CASE CAST(CASE
@ -222,3 +277,175 @@ SELECT
ELSE duration ELSE duration
END AS INTEGER) AS duration END AS INTEGER) AS duration
FROM rescaled_raw; FROM rescaled_raw;
--------------------------------
--------- User Streaks ---------
--------------------------------
CREATE VIEW IF NOT EXISTS view_user_streaks AS
WITH document_windows AS (
SELECT
activity.user_id,
users.time_offset,
DATE(
activity.start_time,
users.time_offset,
'weekday 0', '-7 day'
) AS weekly_read,
DATE(activity.start_time, users.time_offset) AS daily_read
FROM raw_activity AS activity
LEFT JOIN users ON users.id = activity.user_id
GROUP BY activity.user_id, weekly_read, daily_read
),
weekly_partitions AS (
SELECT
user_id,
time_offset,
'WEEK' AS "window",
weekly_read AS read_window,
row_number() OVER (
PARTITION BY user_id ORDER BY weekly_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, weekly_read
),
daily_partitions AS (
SELECT
user_id,
time_offset,
'DAY' AS "window",
daily_read AS read_window,
row_number() OVER (
PARTITION BY user_id ORDER BY daily_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, daily_read
),
streaks AS (
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
time_offset
FROM daily_partitions
GROUP BY
time_offset,
user_id,
DATE(read_window, '+' || seqnum || ' day')
UNION ALL
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
time_offset
FROM weekly_partitions
GROUP BY
time_offset,
user_id,
DATE(read_window, '+' || (seqnum * 7) || ' day')
),
max_streak AS (
SELECT
MAX(streak) AS max_streak,
start_date AS max_streak_start_date,
end_date AS max_streak_end_date,
window,
user_id
FROM streaks
GROUP BY user_id, window
),
current_streak AS (
SELECT
streak AS current_streak,
start_date AS current_streak_start_date,
end_date AS current_streak_end_date,
window,
user_id
FROM streaks
WHERE CASE
WHEN window = "WEEK" THEN
DATE('now', time_offset, 'weekday 0', '-14 day') = current_streak_end_date
OR DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date
WHEN window = "DAY" THEN
DATE('now', time_offset, '-1 day') = current_streak_end_date
OR DATE('now', time_offset) = current_streak_end_date
END
GROUP BY user_id, window
)
SELECT
max_streak.user_id,
max_streak.window,
IFNULL(max_streak, 0) AS max_streak,
IFNULL(max_streak_start_date, "N/A") AS max_streak_start_date,
IFNULL(max_streak_end_date, "N/A") AS max_streak_end_date,
IFNULL(current_streak, 0) AS current_streak,
IFNULL(current_streak_start_date, "N/A") AS current_streak_start_date,
IFNULL(current_streak_end_date, "N/A") AS current_streak_end_date
FROM max_streak
LEFT JOIN current_streak ON
current_streak.user_id = max_streak.user_id
AND current_streak.window = max_streak.window;
--------------------------------
------- Document Stats ---------
--------------------------------
CREATE VIEW IF NOT EXISTS view_document_user_statistics AS
WITH true_progress AS (
SELECT
document_id,
user_id,
start_time AS last_read,
page,
pages,
SUM(duration) AS total_time_seconds,
-- Determine Read Pages
COUNT(DISTINCT page) AS read_pages,
-- Derive Percentage of Book
ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage
FROM view_rescaled_activity
GROUP BY document_id, user_id
HAVING MAX(start_time)
)
SELECT
true_progress.*,
CAST(COALESCE(documents.words, 0.0) AS REAL) AS words,
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
AS words_read,
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
/ (total_time_seconds / 60.0) AS wpm
FROM true_progress
INNER JOIN documents ON documents.id = true_progress.document_id
ORDER BY wpm DESC;
---------------------------------------------------------------
------------------ Populate Temporary Tables ------------------
---------------------------------------------------------------
INSERT INTO activity SELECT * FROM view_rescaled_activity;
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics;
---------------------------------------------------------------
--------------------------- Triggers --------------------------
---------------------------------------------------------------
-- Update Trigger
CREATE TRIGGER IF NOT EXISTS update_documents_updated_at
BEFORE UPDATE ON documents BEGIN
UPDATE documents
SET updated_at = CURRENT_TIMESTAMP
WHERE id = old.id;
END;

View File

@ -0,0 +1,8 @@
DELETE FROM activity;
INSERT INTO activity SELECT * FROM view_rescaled_activity;
DELETE FROM user_streaks;
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
DELETE FROM document_user_statistics;
INSERT INTO document_user_statistics
SELECT *
FROM view_document_user_statistics;

View File

@ -151,6 +151,7 @@
</div> </div>
<div class="grid grid-cols-1 gap-4 my-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 my-4 md:grid-cols-2 lg:grid-cols-3">
{{ range $item := .Data.Streaks }}
<div class="w-full"> <div class="w-full">
<div <div
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded" class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
@ -158,11 +159,12 @@
<p <p
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500" class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
> >
Daily Read Streak {{ if eq $item.Window "WEEK" }} Weekly Read Streak {{ else }} Daily Read
Streak {{ end }}
</p> </p>
<div class="flex items-end my-6 space-x-2"> <div class="flex items-end my-6 space-x-2">
<p class="text-5xl font-bold text-black dark:text-white"> <p class="text-5xl font-bold text-black dark:text-white">
{{ .Data.DailyStreak.CurrentStreak }} {{ $item.CurrentStreak }}
</p> </p>
</div> </div>
<div class="dark:text-white"> <div class="dark:text-white">
@ -170,76 +172,73 @@
class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200" class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"
> >
<div> <div>
<p>Current Daily Streak</p> <p>
{{ if eq $item.Window "WEEK" }} Current Weekly Streak {{ else }}
Current Daily Streak {{ end }}
</p>
<div class="flex items-end text-sm text-gray-400"> <div class="flex items-end text-sm text-gray-400">
{{ .Data.DailyStreak.CurrentStreakStartDate }} ➞ {{ {{ $item.CurrentStreakStartDate }} ➞ {{ $item.CurrentStreakEndDate
.Data.DailyStreak.CurrentStreakEndDate }} }}
</div> </div>
</div> </div>
<div class="flex items-end font-bold"> <div class="flex items-end font-bold">{{ $item.CurrentStreak }}</div>
{{ .Data.DailyStreak.CurrentStreak }}
</div>
</div> </div>
<div class="flex items-center justify-between pb-2 mb-2 text-sm"> <div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div> <div>
<p>Best Daily Streak</p> <p>
{{ if eq $item.Window "WEEK" }} Best Weekly Streak {{ else }} Best
Daily Streak {{ end }}
</p>
<div class="flex items-end text-sm text-gray-400"> <div class="flex items-end text-sm text-gray-400">
{{ .Data.DailyStreak.MaxStreakStartDate }} ➞ {{ {{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}
.Data.DailyStreak.MaxStreakEndDate }}
</div> </div>
</div> </div>
<div class="flex items-end font-bold"> <div class="flex items-end font-bold">{{ $item.MaxStreak }}</div>
{{ .Data.DailyStreak.MaxStreak }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{ end }}
<div class="w-full">
<div
class="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
>
<div>
<p
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
>
WPM Leaderboard
</p>
<div class="flex items-end my-6 space-x-2">
{{ $length := len .Data.WPMLeaderboard }} {{ if eq $length 0 }}
<p class="text-5xl font-bold text-black dark:text-white">N/A</p>
{{ else }}
<p class="text-5xl font-bold text-black dark:text-white">
{{ (index .Data.WPMLeaderboard 0).UserID }}
</p>
{{ end }}
</div>
</div>
<div class="dark:text-white">
{{ range $index, $item := .Data.WPMLeaderboard }} {{ if lt $index 3 }}
{{ if eq $index 0 }}
<div class="flex items-center justify-between pt-2 pb-2 text-sm">
{{ else }}
<div
class="flex items-center justify-between pt-2 pb-2 text-sm border-t border-gray-200"
>
{{ end }}
<div>
<p>{{ $item.UserID }}</p>
</div>
<div class="flex items-end font-bold">{{ $item.Wpm }} WPM</div>
</div>
{{ end }} {{ end }}
</div>
</div>
</div>
</div> </div>
<div class="w-full"> {{end}}
<div
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
>
<p
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
>
Weekly Read Streak
</p>
<div class="flex items-end my-6 space-x-2">
<p class="text-5xl font-bold text-black dark:text-white">
{{ .Data.WeeklyStreak.CurrentStreak }}
</p>
</div>
<div class="dark:text-white">
<div
class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"
>
<div>
<p>Current Weekly Streak</p>
<div class="flex items-end text-sm text-gray-400">
{{ .Data.WeeklyStreak.CurrentStreakStartDate }} ➞ {{
.Data.WeeklyStreak.CurrentStreakEndDate }}
</div>
</div>
<div class="flex items-end font-bold">
{{ .Data.WeeklyStreak.CurrentStreak }}
</div>
</div>
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div>
<p>Best Weekly Streak</p>
<div class="flex items-end text-sm text-gray-400">
{{ .Data.WeeklyStreak.MaxStreakStartDate }} ➞ {{
.Data.WeeklyStreak.MaxStreakEndDate }}
</div>
</div>
<div class="flex items-end font-bold">
{{ .Data.WeeklyStreak.MaxStreak }}
</div>
</div>
</div>
</div>
</div>
</div> </div>
{{end}}

View File

@ -56,6 +56,10 @@ func GetUTCOffsets() []UTCOffset {
} }
func NiceSeconds(input int64) (result string) { func NiceSeconds(input int64) (result string) {
if input == 0 {
return "N/A"
}
days := math.Floor(float64(input) / 60 / 60 / 24) days := math.Floor(float64(input) / 60 / 60 / 24)
seconds := input % (60 * 60 * 24) seconds := input % (60 * 60 * 24)
hours := math.Floor(float64(seconds) / 60 / 60) hours := math.Floor(float64(seconds) / 60 / 60)