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)
|
templates := make(map[string]*template.Template)
|
||||||
render := multitemplate.NewRenderer()
|
render := multitemplate.NewRenderer()
|
||||||
helperFuncs := template.FuncMap{
|
helperFuncs := template.FuncMap{
|
||||||
"GetSVGGraphData": getSVGGraphData,
|
|
||||||
"GetUTCOffsets": getUTCOffsets,
|
|
||||||
"NiceSeconds": niceSeconds,
|
|
||||||
"dict": dict,
|
"dict": dict,
|
||||||
|
"fields": fields,
|
||||||
|
"getSVGGraphData": getSVGGraphData,
|
||||||
|
"getUTCOffsets": getUTCOffsets,
|
||||||
"hasPrefix": strings.HasPrefix,
|
"hasPrefix": strings.HasPrefix,
|
||||||
|
"niceNumbers": niceNumbers,
|
||||||
|
"niceSeconds": niceSeconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Base
|
// Load Base
|
||||||
|
@ -14,6 +14,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -269,21 +271,46 @@ func (api *API) appGetHome(c *gin.Context) {
|
|||||||
templateVars, auth := api.getBaseTemplateVars("home", c)
|
templateVars, auth := api.getBaseTemplateVars("home", c)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
graphData, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, auth.UserName)
|
graphData, err := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, auth.UserName)
|
||||||
log.Debug("[appGetHome] GetDailyReadStats Performance: ", time.Since(start))
|
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()
|
start = time.Now()
|
||||||
databaseInfo, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, auth.UserName)
|
databaseInfo, err := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, auth.UserName)
|
||||||
log.Debug("[appGetHome] GetDatabaseInfo Performance: ", time.Since(start))
|
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)
|
start = time.Now()
|
||||||
WPMLeaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx)
|
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{
|
templateVars["Data"] = gin.H{
|
||||||
"Streaks": streaks,
|
"Streaks": streaks,
|
||||||
"GraphData": graphData,
|
"GraphData": graphData,
|
||||||
"DatabaseInfo": databaseInfo,
|
"DatabaseInfo": databaseInfo,
|
||||||
"WPMLeaderboard": WPMLeaderboard,
|
"UserStatistics": arrangeUserStatistics(userStatistics),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "page/home", templateVars)
|
c.HTML(http.StatusOK, "page/home", templateVars)
|
||||||
@ -385,7 +412,8 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
|
|||||||
// 1. Consume backup ZIP
|
// 1. Consume backup ZIP
|
||||||
// 2. Move existing to "backup" folder (db, wal, shm, covers, documents)
|
// 2. Move existing to "backup" folder (db, wal, shm, covers, documents)
|
||||||
// 3. Extract backup zip
|
// 3. Extract backup zip
|
||||||
// 4. Restart server?
|
// 4. Invalidate cookies (see in auth.go logout)
|
||||||
|
// 5. Restart server?
|
||||||
case adminBackup:
|
case adminBackup:
|
||||||
// Get File Paths
|
// Get File Paths
|
||||||
fileName := fmt.Sprintf("%s.db", api.Config.DBName)
|
fileName := fmt.Sprintf("%s.db", api.Config.DBName)
|
||||||
@ -1244,3 +1272,80 @@ func errorPage(c *gin.Context, errorCode int, errorMessage string) {
|
|||||||
"Message": errorMessage,
|
"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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/antholume/graph"
|
"reichard.io/antholume/graph"
|
||||||
@ -87,6 +88,24 @@ func niceSeconds(input int64) (result string) {
|
|||||||
return
|
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
|
// Convert Database Array -> Int64 Array
|
||||||
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
|
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
|
||||||
var intData []int64
|
var intData []int64
|
||||||
@ -111,3 +130,17 @@ func dict(values ...interface{}) (map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
return dict, nil
|
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 {
|
type DocumentUserStatistic struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
UserID string `json:"user_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"`
|
Percentage float64 `json:"percentage"`
|
||||||
WordsRead int64 `json:"words_read"`
|
LastRead string `json:"last_read"`
|
||||||
Wpm float64 `json:"wpm"`
|
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 {
|
type Metadatum struct {
|
||||||
@ -106,12 +115,21 @@ type UserStreak struct {
|
|||||||
type ViewDocumentUserStatistic struct {
|
type ViewDocumentUserStatistic struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
UserID string `json:"user_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"`
|
Percentage float64 `json:"percentage"`
|
||||||
WordsRead interface{} `json:"words_read"`
|
LastRead interface{} `json:"last_read"`
|
||||||
Wpm int64 `json:"wpm"`
|
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 {
|
type ViewUserStreak struct {
|
||||||
|
@ -171,7 +171,7 @@ SELECT
|
|||||||
docs.filepath,
|
docs.filepath,
|
||||||
docs.words,
|
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.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
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)
|
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.filepath,
|
||||||
docs.words,
|
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.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
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)
|
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
|
-- name: GetUsers :many
|
||||||
SELECT * FROM users;
|
SELECT * FROM users;
|
||||||
|
|
||||||
-- name: GetWPMLeaderboard :many
|
-- name: GetUserStatistics :many
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
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,
|
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
|
||||||
ROUND(CAST(SUM(words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
|
ROUND(CAST(SUM(total_words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
|
||||||
AS wpm
|
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
|
FROM document_user_statistics
|
||||||
WHERE words_read > 0
|
WHERE total_words_read > 0
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
ORDER BY wpm DESC;
|
ORDER BY total_wpm DESC;
|
||||||
|
|
||||||
-- name: GetWantedDocuments :many
|
-- name: GetWantedDocuments :many
|
||||||
SELECT
|
SELECT
|
||||||
|
@ -534,7 +534,7 @@ SELECT
|
|||||||
docs.filepath,
|
docs.filepath,
|
||||||
docs.words,
|
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.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
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)
|
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.filepath,
|
||||||
docs.words,
|
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.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
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)
|
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
|
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
|
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
|
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
|
WHERE user_id = ?1
|
||||||
@ -1041,54 +1124,6 @@ func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
|
|||||||
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,
|
||||||
|
@ -128,15 +128,29 @@ CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
|
|||||||
current_streak_end_date TEXT NOT NULL
|
current_streak_end_date TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Temporary Document User Statistics Table (Cached from View)
|
||||||
CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
|
CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
|
||||||
document_id TEXT NOT NULL,
|
document_id TEXT NOT NULL,
|
||||||
user_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,
|
percentage REAL NOT NULL,
|
||||||
words_read INTEGER NOT NULL,
|
last_read TEXT NOT NULL,
|
||||||
wpm REAL 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
|
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
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
GROUP BY activity.user_id, weekly_read, daily_read
|
GROUP BY activity.user_id, weekly_read, daily_read
|
||||||
),
|
),
|
||||||
|
|
||||||
weekly_partitions AS (
|
weekly_partitions AS (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@ -190,7 +203,6 @@ weekly_partitions AS (
|
|||||||
FROM document_windows
|
FROM document_windows
|
||||||
GROUP BY user_id, weekly_read
|
GROUP BY user_id, weekly_read
|
||||||
),
|
),
|
||||||
|
|
||||||
daily_partitions AS (
|
daily_partitions AS (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@ -203,7 +215,6 @@ daily_partitions AS (
|
|||||||
FROM document_windows
|
FROM document_windows
|
||||||
GROUP BY user_id, daily_read
|
GROUP BY user_id, daily_read
|
||||||
),
|
),
|
||||||
|
|
||||||
streaks AS (
|
streaks AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS streak,
|
COUNT(*) AS streak,
|
||||||
@ -338,25 +349,121 @@ current_progress AS (
|
|||||||
SELECT
|
SELECT
|
||||||
ga.document_id,
|
ga.document_id,
|
||||||
ga.user_id,
|
ga.user_id,
|
||||||
MAX(start_time) AS last_read,
|
|
||||||
SUM(duration) AS total_time_seconds,
|
|
||||||
SUM(read_percentage) AS read_percentage,
|
|
||||||
cp.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))
|
(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
|
FROM grouped_activity AS ga
|
||||||
INNER JOIN
|
INNER JOIN
|
||||||
current_progress AS cp
|
current_progress AS cp
|
||||||
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
|
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
|
||||||
INNER JOIN
|
INNER JOIN
|
||||||
documents AS d
|
documents AS d
|
||||||
ON d.id = ga.document_id
|
ON ga.document_id = d.id
|
||||||
GROUP BY ga.document_id, ga.user_id
|
GROUP BY ga.document_id, ga.user_id
|
||||||
ORDER BY wpm DESC;
|
ORDER BY total_wpm DESC;
|
||||||
|
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
------------------ Populate Temporary Tables ------------------
|
------------------ Populate Temporary Tables ------------------
|
||||||
|
@ -5,6 +5,12 @@ module.exports = {
|
|||||||
"./assets/local/*.{html,htm,svg,js}",
|
"./assets/local/*.{html,htm,svg,js}",
|
||||||
"./assets/reader/*.{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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
minWidth: {
|
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">
|
<div class="text-xs flex">
|
||||||
<p class="text-gray-400 w-32">Est. Time Left</p>
|
<p class="text-gray-400 w-32">Est. Time Left</p>
|
||||||
<p class="font-medium dark:text-white whitespace-nowrap">
|
<p class="font-medium dark:text-white whitespace-nowrap">
|
||||||
{{ NiceSeconds .TotalTimeLeftSeconds }}
|
{{ niceSeconds .TotalTimeLeftSeconds }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-medium text-lg">
|
<p class="font-medium text-lg">
|
||||||
{{ NiceSeconds .Data.TotalTimeSeconds }}
|
{{ niceSeconds .Data.TotalTimeSeconds }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -75,7 +75,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">Time Read</p>
|
<p class="text-gray-400">Time Read</p>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
{{ NiceSeconds $doc.TotalTimeSeconds }}
|
{{ niceSeconds $doc.TotalTimeSeconds }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
Daily Read Totals
|
Daily Read Totals
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{{ $data := (GetSVGGraphData .Data.GraphData 800 70 )}}
|
{{ $data := (getSVGGraphData .Data.GraphData 800 70 )}}
|
||||||
<svg
|
<svg
|
||||||
viewBox="26 0 755 {{ $data.Height }}"
|
viewBox="26 0 755 {{ $data.Height }}"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
@ -159,6 +159,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{{ range $item := .Data.Streaks }}
|
{{ range $item := .Data.Streaks }}
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@ -211,46 +236,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ 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}}
|
{{end}}
|
||||||
</div>
|
</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"
|
id="time_offset"
|
||||||
name="time_offset"
|
name="time_offset"
|
||||||
>
|
>
|
||||||
{{ range $item := GetUTCOffsets }}
|
{{ range $item := getUTCOffsets }}
|
||||||
<option
|
<option
|
||||||
{{ if (eq $item.Value $.Data.TimeOffset) }}selected{{ end }}
|
{{ if (eq $item.Value $.Data.TimeOffset) }}selected{{ end }}
|
||||||
value="{{ $item.Value }}"
|
value="{{ $item.Value }}"
|
||||||
|
Loading…
Reference in New Issue
Block a user