613 lines
15 KiB
Go
613 lines
15 KiB
Go
package tracker
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const defaultFile = "wordle_scores.json"
|
|
|
|
// Score represents a single Wordle score entry
|
|
type Score struct {
|
|
UserName string `json:"user_name"`
|
|
PuzzleNumber int `json:"puzzle_number"`
|
|
Score string `json:"score"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
// UserStats represents statistics for a single user
|
|
type UserStats struct {
|
|
UserID string
|
|
UserName string
|
|
TotalPlayed int
|
|
TotalSolved int
|
|
AverageScore float64
|
|
ScoreDistribution map[string]int // "3" -> count, "4" -> count, etc.
|
|
CurrentStreak int
|
|
LongestStreak int
|
|
}
|
|
|
|
// LeaderboardEntry represents a user's stats for the leaderboard
|
|
type LeaderboardEntry struct {
|
|
UserID string
|
|
UserName string
|
|
TotalPlayed int
|
|
TotalSolved int
|
|
TotalScore int
|
|
AverageScore float64
|
|
WinRate float64
|
|
}
|
|
|
|
// DailyScores maps user IDs to their scores for a specific date
|
|
type DailyScores map[string]Score
|
|
|
|
// scoresDB maps dates (YYYY-MM-DD) to daily scores
|
|
type scoresDB map[string]DailyScores
|
|
|
|
// Tracker manages score tracking and persistence
|
|
type Tracker struct {
|
|
scores scoresDB
|
|
filename string
|
|
}
|
|
|
|
// New creates a new tracker instance
|
|
func New(filename string) (*Tracker, error) {
|
|
if filename == "" {
|
|
filename = defaultFile
|
|
}
|
|
|
|
tracker := &Tracker{
|
|
filename: filename,
|
|
scores: make(scoresDB),
|
|
}
|
|
|
|
if err := tracker.load(); err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("failed to load scores: %w", err)
|
|
}
|
|
}
|
|
|
|
return tracker, nil
|
|
}
|
|
|
|
func (t *Tracker) load() error {
|
|
data, err := os.ReadFile(t.filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return json.Unmarshal(data, &t.scores)
|
|
}
|
|
|
|
func (t *Tracker) save() error {
|
|
data, err := json.MarshalIndent(t.scores, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal scores: %w", err)
|
|
}
|
|
|
|
return os.WriteFile(t.filename, data, 0644)
|
|
}
|
|
|
|
// Add adds a Wordle score for a user
|
|
func (t *Tracker) Add(userID, userName string, puzzleNumber int, score string, timestamp time.Time) error {
|
|
date := timestamp.Format("2006-01-02")
|
|
|
|
if t.scores[date] == nil {
|
|
t.scores[date] = make(DailyScores)
|
|
}
|
|
|
|
t.scores[date][userID] = Score{
|
|
UserName: userName,
|
|
PuzzleNumber: puzzleNumber,
|
|
Score: score,
|
|
Timestamp: timestamp,
|
|
}
|
|
|
|
return t.save()
|
|
}
|
|
|
|
// HasPostedToday checks if a user has already posted a score today
|
|
func (t *Tracker) HasPostedToday(userID string) bool {
|
|
today := time.Now().Format("2006-01-02")
|
|
if dailyScores, exists := t.scores[today]; exists {
|
|
_, hasPosted := dailyScores[userID]
|
|
return hasPosted
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetForDate retrieves all scores for a specific date
|
|
func (t *Tracker) GetForDate(date string) DailyScores {
|
|
scores, ok := t.scores[date]
|
|
if !ok {
|
|
return make(DailyScores)
|
|
}
|
|
return scores
|
|
}
|
|
|
|
// GetYesterday retrieves scores from yesterday
|
|
func (t *Tracker) GetYesterday() DailyScores {
|
|
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
|
return t.GetForDate(yesterday)
|
|
}
|
|
|
|
// GetToday retrieves scores from today
|
|
func (t *Tracker) GetToday() DailyScores {
|
|
today := time.Now().Format("2006-01-02")
|
|
return t.GetForDate(today)
|
|
}
|
|
|
|
// GetWeek retrieves scores from the last 7 days
|
|
func (t *Tracker) GetWeek() map[string]DailyScores {
|
|
result := make(map[string]DailyScores)
|
|
for i := 0; i < 7; i++ {
|
|
date := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
|
if scores := t.GetForDate(date); len(scores) > 0 {
|
|
result[date] = scores
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetAllDates returns all dates that have scores
|
|
func (t *Tracker) GetAllDates() []string {
|
|
dates := make([]string, 0, len(t.scores))
|
|
for date := range t.scores {
|
|
dates = append(dates, date)
|
|
}
|
|
sort.Strings(dates)
|
|
return dates
|
|
}
|
|
|
|
// GetUserStats returns statistics for a specific user
|
|
func (t *Tracker) GetUserStats(userID string) *UserStats {
|
|
stats := &UserStats{
|
|
UserID: userID,
|
|
TotalPlayed: 0,
|
|
TotalSolved: 0,
|
|
ScoreDistribution: make(map[string]int),
|
|
CurrentStreak: 0,
|
|
LongestStreak: 0,
|
|
}
|
|
|
|
dates := t.GetAllDates()
|
|
if len(dates) == 0 {
|
|
return stats
|
|
}
|
|
|
|
// Calculate totals and distribution
|
|
var totalScore int
|
|
for _, date := range dates {
|
|
if score, exists := t.scores[date][userID]; exists {
|
|
stats.TotalPlayed++
|
|
stats.ScoreDistribution[score.Score]++
|
|
|
|
if score.Score != "X" {
|
|
stats.TotalSolved++
|
|
val, _ := strconv.Atoi(score.Score)
|
|
totalScore += val
|
|
}
|
|
}
|
|
}
|
|
|
|
if stats.TotalSolved > 0 {
|
|
stats.AverageScore = float64(totalScore) / float64(stats.TotalSolved)
|
|
}
|
|
|
|
// Calculate streaks
|
|
currentStreak := 0
|
|
longestStreak := 0
|
|
|
|
// Start from most recent and work backwards for current streak
|
|
for i := len(dates) - 1; i >= 0; i-- {
|
|
if score, exists := t.scores[dates[i]][userID]; exists && score.Score != "X" {
|
|
currentStreak++
|
|
} else if currentStreak > 0 {
|
|
break // Current streak ended
|
|
}
|
|
}
|
|
stats.CurrentStreak = currentStreak
|
|
|
|
// Calculate longest streak
|
|
streak := 0
|
|
for _, date := range dates {
|
|
if score, exists := t.scores[date][userID]; exists && score.Score != "X" {
|
|
streak++
|
|
if streak > longestStreak {
|
|
longestStreak = streak
|
|
}
|
|
} else {
|
|
streak = 0
|
|
}
|
|
}
|
|
stats.LongestStreak = longestStreak
|
|
|
|
return stats
|
|
}
|
|
|
|
// GetLeaderboard returns all-time leaderboard statistics
|
|
func (t *Tracker) GetLeaderboard() []*LeaderboardEntry {
|
|
userMap := make(map[string]*LeaderboardEntry)
|
|
|
|
// Collect all users and their stats
|
|
for _, dailyScores := range t.scores {
|
|
for userID, score := range dailyScores {
|
|
if _, exists := userMap[userID]; !exists {
|
|
userMap[userID] = &LeaderboardEntry{
|
|
UserID: userID,
|
|
UserName: score.UserName, // Use stored name as fallback
|
|
}
|
|
}
|
|
|
|
entry := userMap[userID]
|
|
entry.TotalPlayed++
|
|
|
|
if score.Score != "X" {
|
|
entry.TotalSolved++
|
|
val, _ := strconv.Atoi(score.Score)
|
|
entry.TotalScore += val
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate averages and convert to slice
|
|
entries := make([]*LeaderboardEntry, 0, len(userMap))
|
|
for _, entry := range userMap {
|
|
if entry.TotalSolved > 0 {
|
|
entry.AverageScore = float64(entry.TotalScore) / float64(entry.TotalSolved)
|
|
}
|
|
entry.WinRate = float64(entry.TotalSolved) / float64(entry.TotalPlayed) * 100
|
|
entries = append(entries, entry)
|
|
}
|
|
|
|
// Sort by average score (lower is better), then by win rate
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].TotalSolved == 0 && entries[j].TotalSolved == 0 {
|
|
return entries[i].WinRate > entries[j].WinRate
|
|
}
|
|
if entries[i].TotalSolved == 0 {
|
|
return false
|
|
}
|
|
if entries[j].TotalSolved == 0 {
|
|
return true
|
|
}
|
|
if math.Abs(entries[i].AverageScore-entries[j].AverageScore) < 0.01 {
|
|
return entries[i].WinRate > entries[j].WinRate
|
|
}
|
|
return entries[i].AverageScore < entries[j].AverageScore
|
|
})
|
|
|
|
return entries
|
|
}
|
|
|
|
// UpdateLeaderboardNames updates the display names in leaderboard entries
|
|
// using the provided name resolver function
|
|
func UpdateLeaderboardNames(entries []*LeaderboardEntry, nameResolver func(userID string) string) {
|
|
for _, entry := range entries {
|
|
if name := nameResolver(entry.UserID); name != "" {
|
|
entry.UserName = name
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpdateDailyScoresNames updates the display names in daily scores
|
|
// using the provided name resolver function
|
|
func UpdateDailyScoresNames(scores DailyScores, nameResolver func(userID string) string) {
|
|
for userID, score := range scores {
|
|
if name := nameResolver(userID); name != "" {
|
|
score.UserName = name
|
|
scores[userID] = score
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetGroupStreak returns the current group streak (consecutive days with at least one score)
|
|
func (t *Tracker) GetGroupStreak() int {
|
|
dates := t.GetAllDates()
|
|
if len(dates) == 0 {
|
|
return 0
|
|
}
|
|
|
|
streak := 0
|
|
today := time.Now().Format("2006-01-02")
|
|
|
|
// Check backwards from today
|
|
for i := 0; ; i++ {
|
|
date := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
|
if scores := t.GetForDate(date); len(scores) > 0 {
|
|
streak++
|
|
} else if date == today {
|
|
// No scores today yet, that's ok, continue
|
|
continue
|
|
} else {
|
|
// Streak broken
|
|
break
|
|
}
|
|
|
|
// Stop if we've gone through all our data
|
|
if date < dates[0] {
|
|
break
|
|
}
|
|
}
|
|
|
|
return streak
|
|
}
|
|
|
|
// ScoreEntry is used for sorting
|
|
type ScoreEntry struct {
|
|
UserID string
|
|
Score Score
|
|
}
|
|
|
|
// FormatSummary formats scores into a nice summary message
|
|
func FormatSummary(scores DailyScores) string {
|
|
if len(scores) == 0 {
|
|
return "No Wordle scores were posted yesterday. 😢"
|
|
}
|
|
|
|
// Get puzzle number from first entry
|
|
var puzzleNum int
|
|
for _, score := range scores {
|
|
puzzleNum = score.PuzzleNumber
|
|
break
|
|
}
|
|
|
|
// Sort scores
|
|
var entries []ScoreEntry
|
|
for userID, score := range scores {
|
|
entries = append(entries, ScoreEntry{UserID: userID, Score: score})
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return scoreValue(entries[i].Score.Score) < scoreValue(entries[j].Score.Score)
|
|
})
|
|
|
|
// Group by score
|
|
scoreGroups := make(map[string][]string)
|
|
for _, entry := range entries {
|
|
score := entry.Score.Score
|
|
scoreGroups[score] = append(scoreGroups[score], entry.Score.UserName)
|
|
}
|
|
|
|
// Build summary
|
|
var lines []string
|
|
lines = append(lines, fmt.Sprintf("📊 *Wordle %d Results* 📊\n", puzzleNum))
|
|
|
|
// Get sorted score keys
|
|
var scoreKeys []string
|
|
for score := range scoreGroups {
|
|
scoreKeys = append(scoreKeys, score)
|
|
}
|
|
|
|
sort.Slice(scoreKeys, func(i, j int) bool {
|
|
return scoreValue(scoreKeys[i]) < scoreValue(scoreKeys[j])
|
|
})
|
|
|
|
// Display grouped results
|
|
for _, score := range scoreKeys {
|
|
users := scoreGroups[score]
|
|
|
|
emoji := getEmoji(score)
|
|
userList := strings.Join(users, ", ")
|
|
lines = append(lines, fmt.Sprintf("%s *%s/6*: %s", emoji, score, userList))
|
|
}
|
|
|
|
// Statistics
|
|
totalPlayers := len(scores)
|
|
successful := 0
|
|
totalScore := 0
|
|
|
|
for _, score := range scores {
|
|
if score.Score != "X" {
|
|
successful++
|
|
val, _ := strconv.Atoi(score.Score)
|
|
totalScore += val
|
|
}
|
|
}
|
|
|
|
lines = append(lines, fmt.Sprintf("\n📈 *Stats*: %d/%d solved", successful, totalPlayers))
|
|
|
|
if successful > 0 {
|
|
avgScore := float64(totalScore) / float64(successful)
|
|
lines = append(lines, fmt.Sprintf("Average score: %.2f", avgScore))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// FormatToday formats today's scores
|
|
func FormatToday(scores DailyScores) string {
|
|
if len(scores) == 0 {
|
|
return "No scores posted yet today!"
|
|
}
|
|
|
|
// Get puzzle number
|
|
var puzzleNum int
|
|
for _, score := range scores {
|
|
puzzleNum = score.PuzzleNumber
|
|
break
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, fmt.Sprintf("📊 *Today's Wordle %d Scores So Far* 📊\n", puzzleNum))
|
|
|
|
for _, score := range scores {
|
|
lines = append(lines, fmt.Sprintf("• %s: %s/6", score.UserName, score.Score))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// FormatWeek formats the last 7 days of scores
|
|
func FormatWeek(weekScores map[string]DailyScores) string {
|
|
if len(weekScores) == 0 {
|
|
return "No scores posted in the last 7 days."
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, "📅 *Last 7 Days Summary* 📅\n")
|
|
|
|
// Get sorted dates (most recent first)
|
|
dates := make([]string, 0, len(weekScores))
|
|
for date := range weekScores {
|
|
dates = append(dates, date)
|
|
}
|
|
sort.Sort(sort.Reverse(sort.StringSlice(dates)))
|
|
|
|
totalPlayers := make(map[string]bool)
|
|
totalSolved := 0
|
|
totalAttempts := 0
|
|
|
|
for _, date := range dates {
|
|
scores := weekScores[date]
|
|
solved := 0
|
|
|
|
for userID, score := range scores {
|
|
totalPlayers[userID] = true
|
|
if score.Score != "X" {
|
|
solved++
|
|
totalSolved++
|
|
}
|
|
totalAttempts++
|
|
}
|
|
|
|
// Format date nicely
|
|
t, _ := time.Parse("2006-01-02", date)
|
|
dateStr := t.Format("Mon, Jan 2")
|
|
|
|
lines = append(lines, fmt.Sprintf("*%s*: %d played, %d solved", dateStr, len(scores), solved))
|
|
}
|
|
|
|
lines = append(lines, fmt.Sprintf("\n📈 *Week Stats*:"))
|
|
lines = append(lines, fmt.Sprintf("• Unique players: %d", len(totalPlayers)))
|
|
lines = append(lines, fmt.Sprintf("• Total puzzles: %d", totalAttempts))
|
|
lines = append(lines, fmt.Sprintf("• Solved: %d (%.1f%%)", totalSolved, float64(totalSolved)/float64(totalAttempts)*100))
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// FormatLeaderboard formats the all-time leaderboard
|
|
func FormatLeaderboard(entries []*LeaderboardEntry, limit int) string {
|
|
if len(entries) == 0 {
|
|
return "No leaderboard data available yet."
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, "🏆 *All-Time Leaderboard* 🏆\n")
|
|
|
|
if limit > len(entries) {
|
|
limit = len(entries)
|
|
}
|
|
|
|
for i := 0; i < limit; i++ {
|
|
entry := entries[i]
|
|
medal := ""
|
|
switch i {
|
|
case 0:
|
|
medal = "🥇"
|
|
case 1:
|
|
medal = "🥈"
|
|
case 2:
|
|
medal = "🥉"
|
|
default:
|
|
medal = fmt.Sprintf("%d.", i+1)
|
|
}
|
|
|
|
if entry.TotalSolved > 0 {
|
|
lines = append(lines, fmt.Sprintf("%s *%s* - Avg: %.2f, Win Rate: %.0f%% (%d/%d)",
|
|
medal, entry.UserName, entry.AverageScore, entry.WinRate, entry.TotalSolved, entry.TotalPlayed))
|
|
} else {
|
|
lines = append(lines, fmt.Sprintf("%s *%s* - 0/%d solved",
|
|
medal, entry.UserName, entry.TotalPlayed))
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// FormatUserStats formats personal statistics
|
|
func FormatUserStats(stats *UserStats, groupStreak int) string {
|
|
if stats.TotalPlayed == 0 {
|
|
return "You haven't posted any Wordle scores yet!"
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, fmt.Sprintf("📊 *Your Wordle Stats* 📊\n"))
|
|
|
|
// Overall stats
|
|
lines = append(lines, fmt.Sprintf("*Games Played:* %d", stats.TotalPlayed))
|
|
lines = append(lines, fmt.Sprintf("*Games Solved:* %d (%.0f%%)",
|
|
stats.TotalSolved,
|
|
float64(stats.TotalSolved)/float64(stats.TotalPlayed)*100))
|
|
|
|
if stats.TotalSolved > 0 {
|
|
lines = append(lines, fmt.Sprintf("*Average Score:* %.2f", stats.AverageScore))
|
|
}
|
|
|
|
// Streaks
|
|
lines = append(lines, fmt.Sprintf("\n🔥 *Streaks:*"))
|
|
lines = append(lines, fmt.Sprintf("• Current: %d day%s", stats.CurrentStreak, plural(stats.CurrentStreak)))
|
|
lines = append(lines, fmt.Sprintf("• Longest: %d day%s", stats.LongestStreak, plural(stats.LongestStreak)))
|
|
lines = append(lines, fmt.Sprintf("• Group: %d day%s", groupStreak, plural(groupStreak)))
|
|
|
|
// Score distribution
|
|
if len(stats.ScoreDistribution) > 0 {
|
|
lines = append(lines, fmt.Sprintf("\n📈 *Score Distribution:*"))
|
|
|
|
// Sort keys
|
|
keys := make([]string, 0, len(stats.ScoreDistribution))
|
|
for k := range stats.ScoreDistribution {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
return scoreValue(keys[i]) < scoreValue(keys[j])
|
|
})
|
|
|
|
for _, score := range keys {
|
|
count := stats.ScoreDistribution[score]
|
|
emoji := getEmoji(score)
|
|
bar := strings.Repeat("▓", count) + strings.Repeat("░", stats.TotalPlayed-count)
|
|
if len(bar) > 20 {
|
|
bar = bar[:20]
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s %s/6: %s %d", emoji, score, bar, count))
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func plural(n int) string {
|
|
if n == 1 {
|
|
return ""
|
|
}
|
|
return "s"
|
|
}
|
|
|
|
// scoreValue returns the numeric value for sorting (X = 7)
|
|
func scoreValue(score string) int {
|
|
if score == "X" {
|
|
return 7
|
|
}
|
|
val, _ := strconv.Atoi(score)
|
|
return val
|
|
}
|
|
|
|
// getEmoji returns the appropriate emoji for a score
|
|
func getEmoji(score string) string {
|
|
switch score {
|
|
case "1", "2", "3":
|
|
return "🎯"
|
|
case "X":
|
|
return "❌"
|
|
default:
|
|
return "✅"
|
|
}
|
|
}
|