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)), }, }) }