This commit is contained in:
parent
760b9ca0a0
commit
35ca021649
@ -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
|
||||
|
@ -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
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
33
api/utils.go
33
api/utils.go
@ -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
@ -63,12 +63,21 @@ 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"`
|
||||
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 {
|
||||
@ -106,12 +115,21 @@ 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"`
|
||||
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 {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
@ -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 ------------------
|
||||
|
@ -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: {
|
||||
|
101
templates/components/leaderboard-card.html
Normal file
101
templates/components/leaderboard-card.html
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 }}"
|
||||
|
Loading…
Reference in New Issue
Block a user