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 "āœ…" } }