Files
pargar/backend/internal/api/leaderboard.go
T

152 lines
4.0 KiB
Go
Raw Normal View History

package api
import (
"context"
"net/http"
"sort"
"strconv"
"time"
"pargar/backend/internal/service"
)
type lbEntry struct {
UserID int64 `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
XP float64 `json:"xp"`
Rank int `json:"rank"`
Tier string `json:"tier"`
AvatarVariant string `json:"avatarVariant"`
AvatarPalette string `json:"avatarPalette"`
AvatarPhoto string `json:"avatarPhoto,omitempty"`
}
// tierFromRank assigns a league tier based on rank relative to total participants.
// Thresholds: rank 1 = champion, top 10% = gold, top 30% = silver, rest = bronze.
func tierFromRank(rank, total int) string {
if rank <= 0 || total <= 0 {
return "bronze"
}
if rank == 1 {
return "champion"
}
if rank*10 <= total {
return "gold"
}
if rank*10 <= total*3 {
return "silver"
}
return "bronze"
}
func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
key := "lb:" + service.ISOWeek(time.Now())
type scored struct {
userID int64
xp float64
}
var scoredList []scored
var totalParticipants int64
if s.redis != nil {
z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result()
if err == nil {
for _, member := range z {
id, _ := strconv.ParseInt(member.Member.(string), 10, 64)
scoredList = append(scoredList, scored{userID: id, xp: member.Score})
}
}
if count, err := s.redis.ZCard(context.Background(), key).Result(); err == nil {
totalParticipants = count
}
}
// Fallback to DB if Redis is empty (e.g. dev bootstrap).
if len(scoredList) == 0 {
rows, err := s.store.Pool().Query(context.Background(),
`SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1
ORDER BY xp DESC, user_id ASC LIMIT 20`,
service.ISOWeek(time.Now()))
if err == nil {
for rows.Next() {
var id int64
var xp int
if rows.Scan(&id, &xp) == nil {
scoredList = append(scoredList, scored{userID: id, xp: float64(xp)})
}
}
rows.Close()
}
if err := s.store.Pool().QueryRow(context.Background(),
`SELECT COUNT(*) FROM weekly_leaderboard WHERE week=$1`,
service.ISOWeek(time.Now())).Scan(&totalParticipants); err != nil {
totalParticipants = int64(len(scoredList))
}
}
// Deterministic tie-breaking: XP descending, then user_id ascending.
sort.SliceStable(scoredList, func(i, j int) bool {
if scoredList[i].xp != scoredList[j].xp {
return scoredList[i].xp > scoredList[j].xp
}
return scoredList[i].userID < scoredList[j].userID
})
ids := make([]int64, len(scoredList))
for i, sc := range scoredList {
ids[i] = sc.userID
}
usersMap, _ := s.store.GetUsersByIDs(context.Background(), ids)
entries := make([]lbEntry, 0, len(scoredList))
for i, sc := range scoredList {
usr, ok := usersMap[sc.userID]
if !ok {
continue
}
s.signUserMedia(usr)
rank := i + 1
entries = append(entries, lbEntry{
UserID: sc.userID,
Name: usr.Name,
Email: usr.Email,
XP: sc.xp,
Rank: rank,
Tier: tierFromRank(rank, int(totalParticipants)),
AvatarVariant: usr.AvatarVariant,
AvatarPalette: usr.AvatarPalette,
AvatarPhoto: usr.AvatarPhoto,
})
}
// Current user's position (may be outside top 20).
myRank := -1
var myXP float64
if s.redis != nil {
if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myRank = int(rank) + 1
}
if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myXP = score
}
}
if entries == nil {
entries = []lbEntry{}
}
writeJSON(w, http.StatusOK, map[string]any{
"week": service.ISOWeek(time.Now()),
"entries": entries,
"totalParticipants": totalParticipants,
"me": map[string]any{
"rank": myRank,
"xp": myXP,
"streak": u.StreakCurrent,
"tier": tierFromRank(myRank, int(totalParticipants)),
},
})
}