feat: leaderboard aggregation functionality

feat: leaderboard ui design
This commit is contained in:
Ferdinand Mütsch 2022-10-03 23:52:22 +02:00
parent 1d7ff4bc2a
commit a27fe04919
17 changed files with 216 additions and 19 deletions

View File

@ -1,6 +1,11 @@
package models
import "time"
import (
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/slice"
"strings"
"time"
)
type LeaderboardItem struct {
ID uint `json:"-" gorm:"primary_key; size:32"`
@ -13,3 +18,53 @@ type LeaderboardItem struct {
Key *string `json:"key" gorm:"size:255"` // pointer because nullable
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}
type Leaderboard []*LeaderboardItem
func (l Leaderboard) UserIDs() []string {
return slice.Unique[string](slice.Map[*LeaderboardItem, string](l, func(i int, item *LeaderboardItem) string {
return item.UserID
}))
}
func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard {
return slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key)
})
}
func (l Leaderboard) TopKeys(by uint8) []string {
type keyTotal struct {
Key string
Total time.Duration
}
totalsMapped := make(map[string]*keyTotal, len(l))
for _, item := range l {
if item.Key == nil || item.By == nil || *item.By != by {
continue
}
if _, ok := totalsMapped[*item.Key]; !ok {
totalsMapped[*item.Key] = &keyTotal{Key: *item.Key, Total: 0}
}
totalsMapped[*item.Key].Total += item.Total
}
totals := slice.Map[*keyTotal, keyTotal](maputil.Values[string, *keyTotal](totalsMapped), func(i int, item *keyTotal) keyTotal {
return *item
})
if err := slice.SortByField(totals, "Total", "desc"); err != nil {
return []string{} // TODO
}
return slice.Map[keyTotal, string](totals, func(i int, item keyTotal) string {
return item.Key
})
}
func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string {
return Leaderboard(slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
return item.UserID == userId
})).TopKeys(by)
}

View File

@ -5,7 +5,9 @@ import "github.com/muety/wakapi/models"
type LeaderboardViewModel struct {
User *models.User
By string
Key string
Items []*models.LeaderboardItem
TopKeys []string
ApiKey string
Success string
Error string
@ -20,3 +22,19 @@ func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
s.Error = m
return s
}
func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItem, principal *models.User) string {
if principal != nil && item.UserID == principal.ID {
return "self"
}
if item.Rank == 1 {
return "gold"
}
if item.Rank == 2 {
return "silver"
}
if item.Rank == 3 {
return "bronze"
}
return "default"
}

View File

@ -75,6 +75,7 @@ type IUserRepository interface {
GetByEmail(string) (*models.User, error)
GetByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error)
GetMany([]string) ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetAllByLeaderboard(bool) ([]*models.User, error)
GetByLoggedInAfter(time.Time) ([]*models.User, error)

View File

@ -77,6 +77,17 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
return users, nil
}
func (r *UserRepository) GetMany(ids []string) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Table("users").
Where("id in ?", ids).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
var users []*models.User
if err := r.db.Where(&models.User{ReportsWeekly: reportsEnabled}).Find(&users).Error; err != nil {

View File

@ -2,6 +2,7 @@ package routes
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
@ -45,29 +46,41 @@ func (h *LeaderboardHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.LeaderboardTemplate].Execute(w, h.buildViewModel(r))
if err := templates[conf.LeaderboardTemplate].Execute(w, h.buildViewModel(r)); err != nil {
logbuch.Error(err.Error())
}
}
func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardViewModel {
user := middlewares.GetPrincipal(r)
byParam := strings.ToLower(r.URL.Query().Get("by"))
keyParam := strings.ToLower(r.URL.Query().Get("key"))
var err error
var items []*models.LeaderboardItem
var leaderboard models.Leaderboard
var topKeys []string
if byParam == "" {
items, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days)
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, true)
if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError}
}
} else {
if by, ok := allowedAggregations[byParam]; ok {
items, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by)
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, true)
if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError}
}
topKeys = leaderboard.TopKeys(by)
if len(topKeys) > 0 {
if keyParam == "" {
keyParam = strings.ToLower(topKeys[0])
}
leaderboard = leaderboard.TopByKey(by, keyParam)
}
} else {
return &view.LeaderboardViewModel{Error: fmt.Sprintf("unsupported aggregation '%s'", byParam)}
}
@ -81,7 +94,9 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
return &view.LeaderboardViewModel{
User: user,
By: byParam,
Items: items,
Key: keyParam,
Items: leaderboard,
TopKeys: topKeys,
ApiKey: apiKey,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),

View File

@ -35,9 +35,11 @@ func DefaultTemplateFuncs() template.FuncMap {
"join": strings.Join,
"add": utils.Add,
"capitalize": utils.Capitalize,
"lower": strings.ToLower,
"toRunes": utils.ToRunes,
"localTZOffset": utils.LocalTZOffset,
"entityTypes": models.SummaryTypes,
"strslice": utils.SubSlice[string],
"typeName": typeName,
"isDev": func() bool {
return config.Get().IsDev()

View File

@ -120,11 +120,11 @@ func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
return count > 0, err
}
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey) ([]*models.LeaderboardItem, error) {
return srv.GetAggregatedByInterval(interval, nil)
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, resolveUsers bool) (models.Leaderboard, error) {
return srv.GetAggregatedByInterval(interval, nil, resolveUsers)
}
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, resolveUsers bool) (models.Leaderboard, error) {
// check cache
cacheKey := srv.getHash(interval, by)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
@ -136,6 +136,21 @@ func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.Interval
return nil, err
}
if resolveUsers {
a := models.Leaderboard(items).UserIDs()
println(a)
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
if err != nil {
config.Log().Error("failed to resolve users for leaderboard item - %v", err)
} else {
for _, item := range items {
if u, ok := users[item.UserID]; ok {
item.User = u
}
}
}
}
srv.cache.SetDefault(cacheKey, items)
return items, nil
}

View File

@ -101,8 +101,8 @@ type ILeaderboardService interface {
ScheduleDefault()
Run([]*models.User, *models.IntervalKey, []uint8) error
ExistsAnyByUser(string) (bool, error)
GetByInterval(*models.IntervalKey) ([]*models.LeaderboardItem, error)
GetAggregatedByInterval(*models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
GetByInterval(*models.IntervalKey, bool) (models.Leaderboard, error)
GetAggregatedByInterval(*models.IntervalKey, *uint8, bool) (models.Leaderboard, error)
GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error)
GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error)
}
@ -113,6 +113,8 @@ type IUserService interface {
GetUserByEmail(string) (*models.User, error)
GetUserByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error)
GetMany([]string) ([]*models.User, error)
GetManyMapped([]string) (map[string]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetAllByLeaderboard(bool) ([]*models.User, error)
GetActive(bool) ([]*models.User, error)

View File

@ -2,6 +2,7 @@ package services
import (
"fmt"
"github.com/duke-git/lancet/v2/convertor"
"github.com/duke-git/lancet/v2/datetime"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
@ -96,6 +97,20 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
return srv.repository.GetAll()
}
func (srv *UserService) GetMany(ids []string) ([]*models.User, error) {
return srv.repository.GetMany(ids)
}
func (srv *UserService) GetManyMapped(ids []string) (map[string]*models.User, error) {
users, err := srv.repository.GetMany(ids)
if err != nil {
return nil, err
}
return convertor.ToMap[*models.User, string, *models.User](users, func(u *models.User) (string, *models.User) {
return u.ID, u
}), nil
}
func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
return srv.repository.GetAllByReports(reportsEnabled)
}

View File

@ -69,6 +69,10 @@ body {
@apply py-2 px-4 font-semibold rounded bg-red-600 hover:bg-red-700 text-white text-sm;
}
.btn-small {
@apply py-1 px-2;
}
.input-default {
@apply appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4;
}
@ -109,6 +113,30 @@ body {
@apply border-red-700;
}
.leaderboard-default {
@apply border-gray-700;
}
.leaderboard-self {
margin-left: -10px;
margin-right: -10px;
padding-left: calc(1rem + 10px);
padding-right: calc(1rem + 10px);
@apply border-green-700 bg-gray-800;
}
.leaderboard-gold {
border-color: #ffd700;
}
.leaderboard-silver {
border-color: #c0c0c0;
}
.leaderboard-bronze {
border-color: #cd7f32;
}
::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -15,7 +15,7 @@ Iconify.addCollection({"prefix":"akar-icons","icons":{"chevron-down":{"body":"<g
Iconify.addCollection({"prefix":"ls","icons":{"logout":{"body":"<path d=\"M0 151v391c0 36 15 68 39 93c24 24 55 37 91 37h196v-81H130c-27 0-48-22-48-49V151c0-27 21-48 48-48h196V21H130c-36 0-67 14-91 38c-24 25-39 56-39 92zm215 118v156c0 18 16 33 34 33h181v123c0 11 6 20 16 25c4 1 8 1 10 1c7 0 13-2 18-7l235-235c11-9 10-27 0-37L474 94c-14-15-44-6-44 18v124H249c-18 0-34 15-34 33z\" fill=\"currentColor\"/>","height":672}},"width":717,"height":717,"inlineHeight":1086,"inlineTop":-205,"verticalAlign":-0.2});
Iconify.addCollection({"prefix":"majesticons","icons":{"clipboard-copy":{"body":"<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M10 4a1 1 0 0 0 0 2h4a1 1 0 0 0 0-2h-4zm0-2a3.001 3.001 0 0 0-2.83 2H6a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-4h-8.586l1.293 1.293a1 1 0 0 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 1 1 1.414 1.414L12.414 13H21V7a3 3 0 0 0-3-3h-1.17A3.001 3.001 0 0 0 14 2h-4z\" fill=\"currentColor\"/></g>"}},"width":24,"height":24});
Iconify.addCollection({"prefix":"fa-regular","icons":{"calendar-alt":{"body":"<path d=\"M148 288h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm108-12v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm96 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm-96 96v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm-96 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm96-260v352c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h48V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h128V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h48c26.5 0 48 21.5 48 48zm-48 346V160H48v298c0 3.3 2.7 6 6 6h340c3.3 0 6-2.7 6-6z\" fill=\"currentColor\"/>","width":448}},"width":512,"height":512});
Iconify.addCollection({"prefix":"ph","icons":{"books-bold":{"body":"<path d=\"M237.479 194.54l-8.283-30.91l-33.128-123.64a20.02 20.02 0 0 0-24.495-14.14l-29.546 7.916A19.927 19.927 0 0 0 128 28H96a19.868 19.868 0 0 0-8 1.682A19.868 19.868 0 0 0 80 28H48a20.022 20.022 0 0 0-20 20v160a20.022 20.022 0 0 0 20 20h32a19.868 19.868 0 0 0 8-1.682A19.868 19.868 0 0 0 96 228h32a20.022 20.022 0 0 0 20-20v-69.214l19.932 74.389a19.99 19.99 0 0 0 24.495 14.141l30.91-8.282a20.022 20.022 0 0 0 14.142-24.494zM161.09 94.915l23.183-6.212l18.635 69.547l-23.182 6.212zm12.83-44.849l4.141 15.456l-23.182 6.211l-4.141-15.455zM124 164h-24V52h24zM76 52v16H52V52zM52 92h24v112H52zm48 112v-16h24v16zm90.08-.9l-4.142-15.456l23.182-6.212l4.141 15.456z\" fill=\"currentColor\"/>"}},"width":256,"height":256});
Iconify.addCollection({"prefix":"ph","icons":{"books-bold":{"body":"<path d=\"M237.5 194.5l-8.3-30.9L196.1 40a20.1 20.1 0 0 0-24.5-14.2l-29.6 8a19.6 19.6 0 0 0-14-5.8H96a19.8 19.8 0 0 0-8 1.7a19.8 19.8 0 0 0-8-1.7H48a20.1 20.1 0 0 0-20 20v160a20.1 20.1 0 0 0 20 20h32a19.8 19.8 0 0 0 8-1.7a19.8 19.8 0 0 0 8 1.7h32a20.1 20.1 0 0 0 20-20v-69.2l19.9 74.4a20.1 20.1 0 0 0 19.4 14.8a17.9 17.9 0 0 0 5.1-.7l30.9-8.3a20 20 0 0 0 14.2-24.5zm-76.4-99.6l23.2-6.2l18.6 69.6l-23.2 6.2zm12.8-44.8l4.2 15.4l-23.2 6.2l-4.2-15.4zM124 164h-24V52h24zM76 52v16H52V52zM52 92h24v112H52zm48 112v-16h24v16zm90.1-.9l-4.2-15.5l23.2-6.2l4.2 15.5z\" fill=\"currentColor\"/>"}},"width":256,"height":256});
Iconify.addCollection({"prefix":"fa-solid","icons":{"external-link-alt":{"body":"<path d=\"M432 320h-32a16 16 0 0 0-16 16v112H64V128h144a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16H48a48 48 0 0 0-48 48v352a48 48 0 0 0 48 48h352a48 48 0 0 0 48-48V336a16 16 0 0 0-16-16zM488 0H360c-21.37 0-32.05 25.91-17 41l35.73 35.73L135 320.37a24 24 0 0 0 0 34L157.67 377a24 24 0 0 0 34 0l243.61-243.68L471 169c15 15 41 4.5 41-17V24a24 24 0 0 0-24-24z\" fill=\"currentColor\"/>"}},"width":512,"height":512});
Iconify.addCollection({"prefix":"simple-icons","icons":{"wakatime":{"body":"<path d=\"M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12s12-5.373 12-12S18.627 0 12 0zm0 2.824a9.176 9.176 0 1 1 0 18.352a9.176 9.176 0 0 1 0-18.352zm5.097 5.058c-.327 0-.61.19-.764.45c-1.025 1.463-2.21 3.162-3.288 4.706l-.387-.636a.897.897 0 0 0-.759-.439a.901.901 0 0 0-.788.492l-.357.581l-1.992-2.943a.897.897 0 0 0-.761-.446c-.514 0-.903.452-.903.96a1 1 0 0 0 .207.61l2.719 3.96c.152.272.44.47.776.47a.91.91 0 0 0 .787-.483c.046-.071.23-.368.314-.504l.324.52c-.035-.047.076.113.087.13c.024.031.054.059.078.085c.019.019.04.036.058.052c.036.033.08.056.115.08c.025.016.052.028.076.04c.029.015.06.024.088.035c.058.025.122.027.18.04c.031.004.064.003.092.005c.29 0 .546-.149.707-.36c1.4-2 2.842-4.055 4.099-5.849A.995.995 0 0 0 18 8.842c0-.508-.389-.96-.903-.96\" fill=\"currentColor\"/>"}},"width":24,"height":24});
Iconify.addCollection({"prefix":"heroicons-solid","icons":{"light-bulb":{"body":"<g fill=\"none\"><path d=\"M11 3a1 1 0 1 0-2 0v1a1 1 0 1 0 2 0V3z\" fill=\"currentColor\"/><path d=\"M15.657 5.757a1 1 0 0 0-1.414-1.414l-.707.707a1 1 0 0 0 1.414 1.414l.707-.707z\" fill=\"currentColor\"/><path d=\"M18 10a1 1 0 0 1-1 1h-1a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1z\" fill=\"currentColor\"/><path d=\"M5.05 6.464A1 1 0 1 0 6.464 5.05l-.707-.707a1 1 0 0 0-1.414 1.414l.707.707z\" fill=\"currentColor\"/><path d=\"M5 10a1 1 0 0 1-1 1H3a1 1 0 1 1 0-2h1a1 1 0 0 1 1 1z\" fill=\"currentColor\"/><path d=\"M8 16v-1h4v1a2 2 0 1 1-4 0z\" fill=\"currentColor\"/><path d=\"M12 14c.015-.34.208-.646.477-.859a4 4 0 1 0-4.954 0c.27.213.462.519.476.859h4.002z\" fill=\"currentColor\"/></g>"},"server":{"body":"<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5zm14 1a1 1 0 1 1-2 0a1 1 0 0 1 2 0z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2 13a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-2zm14 1a1 1 0 1 1-2 0a1 1 0 0 1 2 0z\" fill=\"currentColor\"/></g>"}},"width":20,"height":20});

Binary file not shown.

View File

@ -7,6 +7,11 @@ module.exports = {
'newsbox-default',
'newsbox-warning',
'newsbox-danger',
'leaderboard-self',
'leaderboard-default',
'leaderboard-gold',
'leaderboard-silver',
'leaderboard-bronze',
]
},
}

View File

@ -53,3 +53,10 @@ func ParseUserAgent(ua string) (string, string, error) {
}
return groups[0][1], groups[0][2], nil
}
func SubSlice[T any](slice []T, from, to uint) []T {
if int(to) > len(slice) {
to = uint(len(slice))
}
return slice[from:int(to)]
}

View File

@ -20,21 +20,44 @@
<main class="mt-10 flex-grow flex justify-center w-full" id="leaderboard-page">
<div class="flex flex-col flex-grow mt-10">
<h1 class="font-semibold text-3xl text-white m-0 mb-4">Leaderboard</h1>
<h1 class="h1" style="margin-bottom: 0.5rem">Leaderboard</h1>
<ul class="flex space-x-4 mb-16 text-gray-600">
<li class="font-semibold text-2xl {{ if eq .By "" }} text-gray-300 {{ else }} hover:text-gray-500 {{ end }}">
<p class="block text-sm text-gray-300 w-full lg:w-3/4 mb-8">Wakapi's leaderboard shows a ranking of the most active users on this servers, given they opted in to get listed on the public leaderboard (see <i>Settings 🠒 Data</i>). Statistics are updated at least every 12 hours and are based on the users' total coding time in the past seven days. </p>
<ul class="flex space-x-4 mb-4 text-gray-600">
<li class="font-semibold text-xl {{ if eq .By "" }} text-gray-300 {{ else }} hover:text-gray-500 {{ end }}">
<a href="leaderboard">Total</a>
</li>
<li class="font-semibold text-2xl {{ if eq .By "language" }} text-gray-300 {{ else }} hover:text-gray-500 {{ end }}">
<li class="font-semibold text-xl {{ if eq .By "language" }} text-gray-300 {{ else }} hover:text-gray-500 {{ end }}">
<a href="leaderboard?by=language">By Language</a>
</li>
</ul>
<div id="total" class="tab flex flex-col space-y-4">
{{ if ne .By "" }}
<div class="flex space-x-2 mb-4">
{{ range $i, $key := (strslice .TopKeys 0 20) }}
<div class="inline-block">
<a href="leaderboard?by={{ $.By }}&key={{ $key }}" class="{{ if eq $.Key (lower $key) }} btn-primary {{ else }} btn-default {{ end }} btn-small cursor-pointer">{{ $key }}</a>
</div>
{{ end }}
</div>
{{ end }}
<div class="flex flex-col space-y-4 mt-8 text-gray-300 w-full lg:w-3/4">
<ol>
{{ range $i, $item := .Items }}
<li>{{ $item.Rank }} - {{ $item.UserID }} - {{ $item.Total | duration }}</li>
<li class="px-4 py-2 my-2 rounded-md border-2 leaderboard-{{ ($.ColorModifier $item $.User) }} flex justify-between">
<div class="w-2/12"><strong># {{ $item.Rank }}</strong></div>
<div class="flex w-1/2 items-center space-x-4">
{{ if avatarUrlTemplate }}
<img src="{{ $item.User.AvatarURL avatarUrlTemplate }}" width="24px" class="rounded-full border-green-700" alt="User Profile Avatar"/>
{{ else }}
<span class="iconify inline cursor-pointer text-gray-500 rounded-full border-green-700" style="width: 24px; height: 24px" data-icon="ic:round-person"></span>
{{ end }}
<strong>@{{ $item.UserID }}</strong>
</div>
<div class="w-4/12 text-right"><span>{{ $item.Total | duration }}</span></div>
</li>
{{ end }}
</ol>
</div>