add: more statistics
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2024-01-23 23:00:51 -05:00
parent 760b9ca0a0
commit 35ca021649
14 changed files with 558 additions and 150 deletions

View File

@ -181,11 +181,13 @@ func (api *API) generateTemplates() *multitemplate.Renderer {
templates := make(map[string]*template.Template)
render := multitemplate.NewRenderer()
helperFuncs := template.FuncMap{
"GetSVGGraphData": getSVGGraphData,
"GetUTCOffsets": getUTCOffsets,
"NiceSeconds": niceSeconds,
"dict": dict,
"fields": fields,
"getSVGGraphData": getSVGGraphData,
"getUTCOffsets": getUTCOffsets,
"hasPrefix": strings.HasPrefix,
"niceNumbers": niceNumbers,
"niceSeconds": niceSeconds,
}
// Load Base

View File

@ -14,6 +14,8 @@ import (
"os"
"path"
"path/filepath"
"reflect"
"sort"
"strings"
"time"
@ -269,21 +271,46 @@ func (api *API) appGetHome(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("home", c)
start := time.Now()
graphData, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, auth.UserName)
log.Debug("[appGetHome] GetDailyReadStats Performance: ", time.Since(start))
graphData, err := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, auth.UserName)
if err != nil {
log.Error("[appGetHome] GetDailyReadStats DB Error: ", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err))
return
}
log.Debug("[appGetHome] GetDailyReadStats DB Performance: ", time.Since(start))
start = time.Now()
databaseInfo, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, auth.UserName)
log.Debug("[appGetHome] GetDatabaseInfo Performance: ", time.Since(start))
databaseInfo, err := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, auth.UserName)
if err != nil {
log.Error("[appGetHome] GetDatabaseInfo DB Error: ", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err))
return
}
log.Debug("[appGetHome] GetDatabaseInfo DB Performance: ", time.Since(start))
streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, auth.UserName)
WPMLeaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx)
start = time.Now()
streaks, err := api.DB.Queries.GetUserStreaks(api.DB.Ctx, auth.UserName)
if err != nil {
log.Error("[appGetHome] GetUserStreaks DB Error: ", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err))
return
}
log.Debug("[appGetHome] GetUserStreaks DB Performance: ", time.Since(start))
start = time.Now()
userStatistics, err := api.DB.Queries.GetUserStatistics(api.DB.Ctx)
if err != nil {
log.Error("[appGetHome] GetUserStatistics DB Error: ", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err))
return
}
log.Debug("[appGetHome] GetUserStatistics DB Performance: ", time.Since(start))
templateVars["Data"] = gin.H{
"Streaks": streaks,
"GraphData": graphData,
"DatabaseInfo": databaseInfo,
"WPMLeaderboard": WPMLeaderboard,
"UserStatistics": arrangeUserStatistics(userStatistics),
}
c.HTML(http.StatusOK, "page/home", templateVars)
@ -385,7 +412,8 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
// 1. Consume backup ZIP
// 2. Move existing to "backup" folder (db, wal, shm, covers, documents)
// 3. Extract backup zip
// 4. Restart server?
// 4. Invalidate cookies (see in auth.go logout)
// 5. Restart server?
case adminBackup:
// Get File Paths
fileName := fmt.Sprintf("%s.db", api.Config.DBName)
@ -1244,3 +1272,80 @@ func errorPage(c *gin.Context, errorCode int, errorMessage string) {
"Message": errorMessage,
})
}
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H {
// Item Sorter
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]interface{} {
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
sort.SliceStable(sortedData, less)
newData := make([]map[string]interface{}, 0)
for _, item := range sortedData {
v := reflect.Indirect(reflect.ValueOf(item))
var value string
if strings.Contains(key, "Wpm") {
rawVal := v.FieldByName(key).Float()
value = fmt.Sprintf("%.2f WPM", rawVal)
} else if strings.Contains(key, "Seconds") {
rawVal := v.FieldByName(key).Int()
value = niceSeconds(rawVal)
} else if strings.Contains(key, "Words") {
rawVal := v.FieldByName(key).Int()
value = niceNumbers(rawVal)
}
newData = append(newData, map[string]interface{}{
"UserID": item.UserID,
"Value": value,
})
}
return newData
}
return gin.H{
"WPM": gin.H{
"All": sortItem(userStatistics, "TotalWpm", func(i, j int) bool {
return userStatistics[i].TotalWpm > userStatistics[j].TotalWpm
}),
"Year": sortItem(userStatistics, "YearlyWpm", func(i, j int) bool {
return userStatistics[i].YearlyWpm > userStatistics[j].YearlyWpm
}),
"Month": sortItem(userStatistics, "MonthlyWpm", func(i, j int) bool {
return userStatistics[i].MonthlyWpm > userStatistics[j].MonthlyWpm
}),
"Week": sortItem(userStatistics, "WeeklyWpm", func(i, j int) bool {
return userStatistics[i].WeeklyWpm > userStatistics[j].WeeklyWpm
}),
},
"Duration": gin.H{
"All": sortItem(userStatistics, "TotalSeconds", func(i, j int) bool {
return userStatistics[i].TotalSeconds > userStatistics[j].TotalSeconds
}),
"Year": sortItem(userStatistics, "YearlySeconds", func(i, j int) bool {
return userStatistics[i].YearlySeconds > userStatistics[j].YearlySeconds
}),
"Month": sortItem(userStatistics, "MonthlySeconds", func(i, j int) bool {
return userStatistics[i].MonthlySeconds > userStatistics[j].MonthlySeconds
}),
"Week": sortItem(userStatistics, "WeeklySeconds", func(i, j int) bool {
return userStatistics[i].WeeklySeconds > userStatistics[j].WeeklySeconds
}),
},
"Words": gin.H{
"All": sortItem(userStatistics, "TotalWordsRead", func(i, j int) bool {
return userStatistics[i].TotalWordsRead > userStatistics[j].TotalWordsRead
}),
"Year": sortItem(userStatistics, "YearlyWordsRead", func(i, j int) bool {
return userStatistics[i].YearlyWordsRead > userStatistics[j].YearlyWordsRead
}),
"Month": sortItem(userStatistics, "MonthlyWordsRead", func(i, j int) bool {
return userStatistics[i].MonthlyWordsRead > userStatistics[j].MonthlyWordsRead
}),
"Week": sortItem(userStatistics, "WeeklyWordsRead", func(i, j int) bool {
return userStatistics[i].WeeklyWordsRead > userStatistics[j].WeeklyWordsRead
}),
},
}
}

View File

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"math"
"reflect"
"reichard.io/antholume/database"
"reichard.io/antholume/graph"
@ -87,6 +88,24 @@ func niceSeconds(input int64) (result string) {
return
}
func niceNumbers(input int64) string {
if input == 0 {
return "0"
}
abbreviations := []string{"", "k", "M", "B", "T"}
abbrevIndex := int(math.Log10(float64(input)) / 3)
scaledNumber := float64(input) / math.Pow(10, float64(abbrevIndex*3))
if scaledNumber >= 100 {
return fmt.Sprintf("%.0f%s", scaledNumber, abbreviations[abbrevIndex])
} else if scaledNumber >= 10 {
return fmt.Sprintf("%.1f%s", scaledNumber, abbreviations[abbrevIndex])
} else {
return fmt.Sprintf("%.2f%s", scaledNumber, abbreviations[abbrevIndex])
}
}
// Convert Database Array -> Int64 Array
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
var intData []int64
@ -111,3 +130,17 @@ func dict(values ...interface{}) (map[string]interface{}, error) {
}
return dict, nil
}
func fields(value interface{}) (map[string]interface{}, error) {
v := reflect.Indirect(reflect.ValueOf(value))
if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("%T is not a struct", value)
}
m := make(map[string]interface{})
t := v.Type()
for i := 0; i < t.NumField(); i++ {
sv := t.Field(i)
m[sv.Name] = v.Field(i).Interface()
}
return m, nil
}

File diff suppressed because one or more lines are too long

View File

@ -61,14 +61,23 @@ type DocumentProgress struct {
}
type DocumentUserStatistic struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
LastRead string `json:"last_read"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
ReadPercentage float64 `json:"read_percentage"`
Percentage float64 `json:"percentage"`
WordsRead int64 `json:"words_read"`
Wpm float64 `json:"wpm"`
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read"`
ReadPercentage float64 `json:"read_percentage"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
TotalWordsRead int64 `json:"total_words_read"`
TotalWpm float64 `json:"total_wpm"`
YearlyTimeSeconds int64 `json:"yearly_time_seconds"`
YearlyWordsRead int64 `json:"yearly_words_read"`
YearlyWpm float64 `json:"yearly_wpm"`
MonthlyTimeSeconds int64 `json:"monthly_time_seconds"`
MonthlyWordsRead int64 `json:"monthly_words_read"`
MonthlyWpm float64 `json:"monthly_wpm"`
WeeklyTimeSeconds int64 `json:"weekly_time_seconds"`
WeeklyWordsRead int64 `json:"weekly_words_read"`
WeeklyWpm float64 `json:"weekly_wpm"`
}
type Metadatum struct {
@ -104,14 +113,23 @@ type UserStreak struct {
}
type ViewDocumentUserStatistic struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
LastRead interface{} `json:"last_read"`
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
ReadPercentage sql.NullFloat64 `json:"read_percentage"`
Percentage float64 `json:"percentage"`
WordsRead interface{} `json:"words_read"`
Wpm int64 `json:"wpm"`
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
Percentage float64 `json:"percentage"`
LastRead interface{} `json:"last_read"`
ReadPercentage sql.NullFloat64 `json:"read_percentage"`
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
TotalWordsRead interface{} `json:"total_words_read"`
TotalWpm int64 `json:"total_wpm"`
YearlyTimeSeconds sql.NullFloat64 `json:"yearly_time_seconds"`
YearlyWordsRead interface{} `json:"yearly_words_read"`
YearlyWpm interface{} `json:"yearly_wpm"`
MonthlyTimeSeconds sql.NullFloat64 `json:"monthly_time_seconds"`
MonthlyWordsRead interface{} `json:"monthly_words_read"`
MonthlyWpm interface{} `json:"monthly_wpm"`
WeeklyTimeSeconds sql.NullFloat64 `json:"weekly_time_seconds"`
WeeklyWordsRead interface{} `json:"weekly_words_read"`
WeeklyWpm interface{} `json:"weekly_wpm"`
}
type ViewUserStreak struct {

View File

@ -171,7 +171,7 @@ SELECT
docs.filepath,
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
@ -223,7 +223,7 @@ SELECT
docs.filepath,
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
@ -308,17 +308,34 @@ WHERE user_id = $user_id;
-- name: GetUsers :many
SELECT * FROM users;
-- name: GetWPMLeaderboard :many
-- name: GetUserStatistics :many
SELECT
user_id,
CAST(SUM(words_read) AS INTEGER) AS total_words_read,
CAST(SUM(total_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
ROUND(CAST(SUM(total_words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
AS total_wpm,
CAST(SUM(yearly_words_read) AS INTEGER) AS yearly_words_read,
CAST(SUM(yearly_time_seconds) AS INTEGER) AS yearly_seconds,
ROUND(CAST(SUM(yearly_words_read) AS REAL) / (SUM(yearly_time_seconds) / 60.0), 2)
AS yearly_wpm,
CAST(SUM(monthly_words_read) AS INTEGER) AS monthly_words_read,
CAST(SUM(monthly_time_seconds) AS INTEGER) AS monthly_seconds,
ROUND(CAST(SUM(monthly_words_read) AS REAL) / (SUM(monthly_time_seconds) / 60.0), 2)
AS monthly_wpm,
CAST(SUM(weekly_words_read) AS INTEGER) AS weekly_words_read,
CAST(SUM(weekly_time_seconds) AS INTEGER) AS weekly_seconds,
ROUND(CAST(SUM(weekly_words_read) AS REAL) / (SUM(weekly_time_seconds) / 60.0), 2)
AS weekly_wpm
FROM document_user_statistics
WHERE words_read > 0
WHERE total_words_read > 0
GROUP BY user_id
ORDER BY wpm DESC;
ORDER BY total_wpm DESC;
-- name: GetWantedDocuments :many
SELECT

View File

@ -534,7 +534,7 @@ SELECT
docs.filepath,
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
@ -688,7 +688,7 @@ SELECT
docs.filepath,
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
@ -971,6 +971,89 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
return i, err
}
const getUserStatistics = `-- name: GetUserStatistics :many
SELECT
user_id,
CAST(SUM(total_words_read) AS INTEGER) AS total_words_read,
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
ROUND(CAST(SUM(total_words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
AS total_wpm,
CAST(SUM(yearly_words_read) AS INTEGER) AS yearly_words_read,
CAST(SUM(yearly_time_seconds) AS INTEGER) AS yearly_seconds,
ROUND(CAST(SUM(yearly_words_read) AS REAL) / (SUM(yearly_time_seconds) / 60.0), 2)
AS yearly_wpm,
CAST(SUM(monthly_words_read) AS INTEGER) AS monthly_words_read,
CAST(SUM(monthly_time_seconds) AS INTEGER) AS monthly_seconds,
ROUND(CAST(SUM(monthly_words_read) AS REAL) / (SUM(monthly_time_seconds) / 60.0), 2)
AS monthly_wpm,
CAST(SUM(weekly_words_read) AS INTEGER) AS weekly_words_read,
CAST(SUM(weekly_time_seconds) AS INTEGER) AS weekly_seconds,
ROUND(CAST(SUM(weekly_words_read) AS REAL) / (SUM(weekly_time_seconds) / 60.0), 2)
AS weekly_wpm
FROM document_user_statistics
WHERE total_words_read > 0
GROUP BY user_id
ORDER BY total_wpm DESC
`
type GetUserStatisticsRow struct {
UserID string `json:"user_id"`
TotalWordsRead int64 `json:"total_words_read"`
TotalSeconds int64 `json:"total_seconds"`
TotalWpm float64 `json:"total_wpm"`
YearlyWordsRead int64 `json:"yearly_words_read"`
YearlySeconds int64 `json:"yearly_seconds"`
YearlyWpm float64 `json:"yearly_wpm"`
MonthlyWordsRead int64 `json:"monthly_words_read"`
MonthlySeconds int64 `json:"monthly_seconds"`
MonthlyWpm float64 `json:"monthly_wpm"`
WeeklyWordsRead int64 `json:"weekly_words_read"`
WeeklySeconds int64 `json:"weekly_seconds"`
WeeklyWpm float64 `json:"weekly_wpm"`
}
func (q *Queries) GetUserStatistics(ctx context.Context) ([]GetUserStatisticsRow, error) {
rows, err := q.db.QueryContext(ctx, getUserStatistics)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserStatisticsRow
for rows.Next() {
var i GetUserStatisticsRow
if err := rows.Scan(
&i.UserID,
&i.TotalWordsRead,
&i.TotalSeconds,
&i.TotalWpm,
&i.YearlyWordsRead,
&i.YearlySeconds,
&i.YearlyWpm,
&i.MonthlyWordsRead,
&i.MonthlySeconds,
&i.MonthlyWpm,
&i.WeeklyWordsRead,
&i.WeeklySeconds,
&i.WeeklyWpm,
); 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 getUserStreaks = `-- name: GetUserStreaks :many
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
WHERE user_id = ?1
@ -1041,54 +1124,6 @@ func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
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
SELECT
CAST(value AS TEXT) AS id,

View File

@ -128,15 +128,29 @@ CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
current_streak_end_date TEXT NOT NULL
);
-- Temporary Document User Statistics Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
document_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read TEXT NOT NULL,
total_time_seconds INTEGER NOT NULL,
read_percentage REAL NOT NULL,
percentage REAL NOT NULL,
words_read INTEGER NOT NULL,
wpm REAL NOT NULL,
last_read TEXT NOT NULL,
read_percentage REAL NOT NULL,
total_time_seconds INTEGER NOT NULL,
total_words_read INTEGER NOT NULL,
total_wpm REAL NOT NULL,
yearly_time_seconds INTEGER NOT NULL,
yearly_words_read INTEGER NOT NULL,
yearly_wpm REAL NOT NULL,
monthly_time_seconds INTEGER NOT NULL,
monthly_words_read INTEGER NOT NULL,
monthly_wpm REAL NOT NULL,
weekly_time_seconds INTEGER NOT NULL,
weekly_words_read INTEGER NOT NULL,
weekly_wpm REAL NOT NULL,
UNIQUE(document_id, user_id) ON CONFLICT REPLACE
);
@ -177,7 +191,6 @@ WITH document_windows AS (
LEFT JOIN users ON users.id = activity.user_id
GROUP BY activity.user_id, weekly_read, daily_read
),
weekly_partitions AS (
SELECT
user_id,
@ -190,7 +203,6 @@ weekly_partitions AS (
FROM document_windows
GROUP BY user_id, weekly_read
),
daily_partitions AS (
SELECT
user_id,
@ -203,7 +215,6 @@ daily_partitions AS (
FROM document_windows
GROUP BY user_id, daily_read
),
streaks AS (
SELECT
COUNT(*) AS streak,
@ -327,8 +338,8 @@ current_progress AS (
WHERE
dp.user_id = iga.user_id
AND dp.document_id = iga.document_id
ORDER BY created_at DESC
LIMIT 1
ORDER BY created_at DESC
LIMIT 1
), end_percentage) AS percentage
FROM intermediate_ga AS iga
GROUP BY user_id, document_id
@ -338,25 +349,121 @@ current_progress AS (
SELECT
ga.document_id,
ga.user_id,
MAX(start_time) AS last_read,
SUM(duration) AS total_time_seconds,
SUM(read_percentage) AS read_percentage,
cp.percentage,
MAX(start_time) AS last_read,
SUM(read_percentage) AS read_percentage,
-- All Time WPM
SUM(duration) AS total_time_seconds,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
AS words_read,
AS total_words_read,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
/ (SUM(duration) / 60.0) AS total_wpm,
-- Yearly WPM
SUM(CASE WHEN start_time >= DATE('now', '-1 year') THEN duration ELSE 0 END)
AS yearly_time_seconds,
(
CAST(COALESCE(d.words, 0.0) AS REAL)
* SUM(
CASE
WHEN start_time >= DATE('now', '-1 year') THEN read_percentage
ELSE 0
END
)
)
AS yearly_words_read,
COALESCE((
CAST(COALESCE(d.words, 0.0) AS REAL)
* SUM(
CASE
WHEN start_time >= DATE('now', '-1 year') THEN read_percentage
END
)
)
/ (
SUM(
CASE
WHEN start_time >= DATE('now', '-1 year') THEN duration
END
)
/ 60.0
), 0.0)
AS yearly_wpm,
-- Monthly WPM
SUM(
CASE WHEN start_time >= DATE('now', '-1 month') THEN duration ELSE 0 END
)
AS monthly_time_seconds,
(
CAST(COALESCE(d.words, 0.0) AS REAL)
* SUM(
CASE
WHEN start_time >= DATE('now', '-1 month') THEN read_percentage
ELSE 0
END
)
)
AS monthly_words_read,
COALESCE((
CAST(COALESCE(d.words, 0.0) AS REAL)
* SUM(
CASE
WHEN start_time >= DATE('now', '-1 month') THEN read_percentage
END
)
)
/ (
SUM(
CASE
WHEN start_time >= DATE('now', '-1 month') THEN duration
END
)
/ 60.0
), 0.0)
AS monthly_wpm,
-- Weekly WPM
SUM(CASE WHEN start_time >= DATE('now', '-7 days') THEN duration ELSE 0 END)
AS weekly_time_seconds,
(
CAST(COALESCE(d.words, 0.0) AS REAL)
* SUM(
CASE
WHEN start_time >= DATE('now', '-7 days') THEN read_percentage
ELSE 0
END
)
)
AS weekly_words_read,
COALESCE((
CAST(COALESCE(d.words, 0.0) AS REAL)
* SUM(
CASE
WHEN start_time >= DATE('now', '-7 days') THEN read_percentage
END
)
)
/ (
SUM(
CASE
WHEN start_time >= DATE('now', '-7 days') THEN duration
END
)
/ 60.0
), 0.0)
AS weekly_wpm
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
/ (SUM(duration) / 60.0) AS wpm
FROM grouped_activity AS ga
INNER JOIN
current_progress AS cp
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
INNER JOIN
documents AS d
ON d.id = ga.document_id
ON ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id
ORDER BY wpm DESC;
ORDER BY total_wpm DESC;
---------------------------------------------------------------
------------------ Populate Temporary Tables ------------------

View File

@ -5,6 +5,12 @@ module.exports = {
"./assets/local/*.{html,htm,svg,js}",
"./assets/reader/*.{html,htm,svg,js}",
],
safelist: [
"peer-checked/All:block",
"peer-checked/Year:block",
"peer-checked/Month:block",
"peer-checked/Week:block",
],
theme: {
extend: {
minWidth: {

View File

@ -0,0 +1,101 @@
<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>
<div class="flex justify-between">
<p
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
>
{{ .Name }} Leaderboard
</p>
<div class="flex gap-2 text-xs text-gray-400 items-center">
<label
for="all-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white"
>all</label
>
<label
for="year-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white"
>year</label
>
<label
for="month-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white"
>month</label
>
<label
for="week-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white"
>week</label
>
</div>
</div>
</div>
<input
type="radio"
name="options-{{ .Name }}"
id="all-{{ .Name }}"
class="hidden peer/All"
checked
/>
<input
type="radio"
name="options-{{ .Name }}"
id="year-{{ .Name }}"
class="hidden peer/Year"
/>
<input
type="radio"
name="options-{{ .Name }}"
id="month-{{ .Name }}"
class="hidden peer/Month"
/>
<input
type="radio"
name="options-{{ .Name }}"
id="week-{{ .Name }}"
class="hidden peer/Week"
/>
{{ range $key, $data := .Data }}
<div class="flex items-end my-6 space-x-2 hidden peer-checked/{{ $key }}:block">
{{ $length := len $data }}
{{ 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 0).UserID }}
</p>
{{ end }}
</div>
<div class="hidden dark:text-white peer-checked/{{ $key }}:block">
{{ range $index, $item := $data }}
{{ 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.Value }}
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end}}
</div>
</div>

View File

@ -214,13 +214,13 @@
<div class="text-xs flex">
<p class="text-gray-400 w-32">Est. Time Left</p>
<p class="font-medium dark:text-white whitespace-nowrap">
{{ NiceSeconds .TotalTimeLeftSeconds }}
{{ niceSeconds .TotalTimeLeftSeconds }}
</p>
</div>
</div>
</div>
<p class="font-medium text-lg">
{{ NiceSeconds .Data.TotalTimeSeconds }}
{{ niceSeconds .Data.TotalTimeSeconds }}
</p>
</div>
<div>

View File

@ -75,7 +75,7 @@
<div>
<p class="text-gray-400">Time Read</p>
<p class="font-medium">
{{ NiceSeconds $doc.TotalTimeSeconds }}
{{ niceSeconds $doc.TotalTimeSeconds }}
</p>
</div>
</div>

View File

@ -12,7 +12,7 @@
Daily Read Totals
</p>
{{ $data := (GetSVGGraphData .Data.GraphData 800 70 )}}
{{ $data := (getSVGGraphData .Data.GraphData 800 70 )}}
<svg
viewBox="26 0 755 {{ $data.Height }}"
preserveAspectRatio="none"
@ -159,6 +159,31 @@
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{{ template "component/leaderboard-card" (
dict
"Name" "WPM"
"Data" .Data.UserStatistics.WPM
)
}}
{{ template "component/leaderboard-card" (
dict
"Name" "Duration"
"Data" .Data.UserStatistics.Duration
)
}}
{{ template "component/leaderboard-card" (
dict
"Name" "Words"
"Data" .Data.UserStatistics.Words
)
}}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{{ range $item := .Data.Streaks }}
<div class="w-full">
@ -211,46 +236,5 @@
</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>
{{end}}
</div>
</div>

View File

@ -87,7 +87,7 @@
id="time_offset"
name="time_offset"
>
{{ range $item := GetUTCOffsets }}
{{ range $item := getUTCOffsets }}
<option
{{ if (eq $item.Value $.Data.TimeOffset) }}selected{{ end }}
value="{{ $item.Value }}"