| package main |
|
|
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "net/http" |
| "sort" |
| "strings" |
| "time" |
|
|
| "github.com/golang-jwt/jwt/v5" |
| "go.mongodb.org/mongo-driver/bson" |
| "go.mongodb.org/mongo-driver/bson/primitive" |
| "go.mongodb.org/mongo-driver/mongo" |
| "go.mongodb.org/mongo-driver/mongo/options" |
| ) |
|
|
| type progressResponse struct { |
| SolvedQuestionIDs []string `json:"solvedQuestionIds"` |
| DailyActivity map[string]int `json:"dailyActivity"` |
| DailyActivityBreakdown map[string]map[string]int `json:"dailyActivityBreakdown"` |
| SolvedCount int `json:"solvedCount"` |
| ActiveDays int `json:"activeDays"` |
| WeeklySolved int `json:"weeklySolved"` |
| Leaderboard []progressLeaderboardUser `json:"leaderboard"` |
| CodingLeaderboard []progressLeaderboardUser `json:"codingLeaderboard"` |
| CodingSolvedCount int `json:"codingSolvedCount"` |
| } |
|
|
| type progressLeaderboardUser struct { |
| ID string `json:"id"` |
| DisplayName string `json:"displayName"` |
| SolvedCount int `json:"solvedCount"` |
| CurrentStreak int `json:"currentStreak"` |
| WeeklySolved int `json:"weeklySolved"` |
| Coins int `json:"coins"` |
| Score int `json:"score"` |
| Rank int `json:"rank"` |
| IsCurrentUser bool `json:"isCurrentUser"` |
| CodingStreak int `json:"codingStreak,omitempty"` |
| LanguageStats map[string]int `json:"languageStats,omitempty"` |
| } |
|
|
| type importedProgressBody struct { |
| SolvedQuestionIDs []string `json:"solvedQuestionIds"` |
| DailyActivity map[string]int `json:"dailyActivity"` |
| } |
|
|
| type streakSnapshot struct { |
| CurrentStreak int |
| LongestStreak int |
| LastActiveAt time.Time |
| } |
|
|
| var solveCoinRewards = map[string]int{ |
| "easy": 10, |
| "medium": 25, |
| "hard": 50, |
| } |
|
|
| func handleUserStats(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| doc, requestTimeZone, statusCode, err := authenticatedUser(r, coll) |
| if err != nil { |
| jsonError(w, statusCode, err.Error()) |
| return |
| } |
|
|
| if err := syncUserTimeZone(r.Context(), coll, doc, requestTimeZone); err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to load stats.") |
| return |
| } |
|
|
| stats, err := buildProgressResponse(r.Context(), coll, doc, time.Now().UTC()) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to load stats.") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "user": toPublicUser(doc), |
| "stats": stats, |
| }) |
| } |
| } |
|
|
| func handleSolveQuestion(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| doc, requestTimeZone, statusCode, err := authenticatedUser(r, coll) |
| if err != nil { |
| jsonError(w, statusCode, err.Error()) |
| return |
| } |
|
|
| var body struct { |
| QuestionID string `json:"questionId"` |
| Difficulty string `json:"difficulty"` |
| Language string `json:"language,omitempty"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
|
|
| questionID := strings.TrimSpace(body.QuestionID) |
| if questionID == "" { |
| jsonError(w, http.StatusBadRequest, "Question ID is required.") |
| return |
| } |
|
|
| reward, ok := rewardForDifficulty(body.Difficulty) |
| if !ok { |
| jsonError(w, http.StatusBadRequest, "Question difficulty is required.") |
| return |
| } |
|
|
| now := time.Now().UTC() |
| if err := syncUserTimeZone(r.Context(), coll, doc, requestTimeZone); err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to record solve.") |
| return |
| } |
|
|
| solvedQuestionIDs := normalizeQuestionIDs(doc.SolvedQuestionIDs) |
| dailyActivity := normalizeDailyActivity(doc.DailyActivity) |
| dailyActivityBreakdown := normalizeDailyActivityBreakdown(doc.DailyActivityBreakdown) |
| alreadySolved := containsQuestionID(solvedQuestionIDs, questionID) |
|
|
| if !alreadySolved { |
| solvedQuestionIDs = append(solvedQuestionIDs, questionID) |
| activityKey := activityDateKey(now, doc.StreakTimeZone) |
| |
| dailyActivity[activityKey] = dailyActivity[activityKey] + 1 |
| |
| if dailyActivityBreakdown[activityKey] == nil { |
| dailyActivityBreakdown[activityKey] = make(map[string]int) |
| } |
| |
| section := "coding" |
| if strings.HasPrefix(questionID, "sd-") { |
| section = "systemDesign" |
| } else if strings.HasPrefix(questionID, "cs-dbms-") { |
| section = "dbms" |
| } else if strings.HasPrefix(questionID, "cs-os-") { |
| section = "os" |
| } else if strings.HasPrefix(questionID, "cs-cn-") { |
| section = "cn" |
| } else if strings.HasPrefix(questionID, "cs-") { |
| section = "csFundamentals" |
| } else if strings.HasPrefix(questionID, "apt-") { |
| section = "aptitude" |
| } |
| dailyActivityBreakdown[activityKey][section] = dailyActivityBreakdown[activityKey][section] + 1 |
| |
| languageStats := doc.LanguageStats |
| if languageStats == nil { |
| languageStats = make(map[string]int) |
| } |
| if section == "coding" && body.Language != "" { |
| lang := strings.ToLower(strings.TrimSpace(body.Language)) |
| languageStats[lang] = languageStats[lang] + 1 |
| } |
|
|
| nextStreak := nextStreakUpdate(doc, requestTimeZone, now) |
| doc.SolvedQuestionIDs = solvedQuestionIDs |
| doc.DailyActivity = dailyActivity |
| doc.DailyActivityBreakdown = dailyActivityBreakdown |
| doc.CurrentStreak = nextStreak.CurrentStreak |
| doc.LongestStreak = nextStreak.LongestStreak |
| doc.LastActiveAt = nextStreak.LastActiveAt |
| doc.StreakTimeZone = nextStreak.StreakTimeZone |
| doc.Coins = maxInt(doc.Coins, 0) + reward |
| doc.LanguageStats = languageStats |
|
|
| if reward > 0 { |
| difficultyLabel := body.Difficulty |
| if len(difficultyLabel) > 0 { |
| difficultyLabel = strings.ToUpper(difficultyLabel[:1]) + strings.ToLower(difficultyLabel[1:]) |
| } |
| |
| sectionName := "Coding Problem" |
| if section == "systemDesign" { |
| sectionName = "System Design" |
| } else if section == "dbms" { |
| sectionName = "DBMS" |
| } else if section == "os" { |
| sectionName = "OS" |
| } else if section == "cn" { |
| sectionName = "Computer Networks" |
| } else if section == "csFundamentals" { |
| sectionName = "CS Fundamentals" |
| } else if section == "aptitude" { |
| sectionName = "Aptitude" |
| } |
| |
| _ = recordCoinTransaction(r.Context(), coll, doc.ID, reward, sectionName+" ("+difficultyLabel+")", "problem") |
| } |
|
|
| _, err = coll.UpdateByID(r.Context(), doc.ID, bson.M{ |
| "$set": bson.M{ |
| "solvedQuestionIds": solvedQuestionIDs, |
| "dailyActivity": dailyActivity, |
| "dailyActivityBreakdown": dailyActivityBreakdown, |
| "currentStreak": doc.CurrentStreak, |
| "longestStreak": doc.LongestStreak, |
| "lastActiveAt": doc.LastActiveAt, |
| "streakTimeZone": doc.StreakTimeZone, |
| "coins": doc.Coins, |
| "languageStats": doc.LanguageStats, |
| }, |
| }) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to record solve.") |
| return |
| } |
| } |
|
|
| stats, err := buildProgressResponse(r.Context(), coll, doc, now) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to load updated stats.") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "user": toPublicUser(doc), |
| "stats": stats, |
| }) |
| } |
| } |
|
|
| func handleImportProgress(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| doc, requestTimeZone, statusCode, err := authenticatedUser(r, coll) |
| if err != nil { |
| jsonError(w, statusCode, err.Error()) |
| return |
| } |
|
|
| var body importedProgressBody |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
|
|
| if err := syncUserTimeZone(r.Context(), coll, doc, requestTimeZone); err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to import progress.") |
| return |
| } |
|
|
| mergedSolved := mergeQuestionIDs(doc.SolvedQuestionIDs, body.SolvedQuestionIDs) |
| mergedActivity := mergeDailyActivity(doc.DailyActivity, body.DailyActivity) |
| snapshot := streakSnapshotFromActivity(mergedActivity, doc.StreakTimeZone) |
|
|
| doc.SolvedQuestionIDs = mergedSolved |
| doc.DailyActivity = mergedActivity |
| if !snapshot.LastActiveAt.IsZero() { |
| doc.CurrentStreak = snapshot.CurrentStreak |
| doc.LongestStreak = maxInt(doc.LongestStreak, snapshot.LongestStreak) |
| doc.LastActiveAt = snapshot.LastActiveAt |
| } |
|
|
| _, err = coll.UpdateByID(r.Context(), doc.ID, bson.M{ |
| "$set": bson.M{ |
| "solvedQuestionIds": mergedSolved, |
| "dailyActivity": mergedActivity, |
| "currentStreak": doc.CurrentStreak, |
| "longestStreak": doc.LongestStreak, |
| "lastActiveAt": doc.LastActiveAt, |
| "streakTimeZone": doc.StreakTimeZone, |
| }, |
| }) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to import progress.") |
| return |
| } |
|
|
| stats, err := buildProgressResponse(r.Context(), coll, doc, time.Now().UTC()) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to load updated stats.") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "user": toPublicUser(doc), |
| "stats": stats, |
| }) |
| } |
| } |
|
|
| func handleSpendCoins(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodPost { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| doc, _, statusCode, err := authenticatedUser(r, coll) |
| if err != nil { |
| jsonError(w, statusCode, err.Error()) |
| return |
| } |
|
|
| var body struct { |
| Amount int `json:"amount"` |
| } |
| if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { |
| jsonError(w, http.StatusBadRequest, "Invalid JSON body") |
| return |
| } |
| if body.Amount <= 0 { |
| jsonError(w, http.StatusBadRequest, "Amount must be greater than 0.") |
| return |
| } |
| if maxInt(doc.Coins, 0) < body.Amount { |
| jsonError(w, http.StatusBadRequest, "Not enough coins.") |
| return |
| } |
|
|
| doc.Coins = maxInt(doc.Coins, 0) - body.Amount |
| _ = recordCoinTransaction(r.Context(), coll, doc.ID, -body.Amount, "Store Purchase", "shop") |
| _, err = coll.UpdateByID(r.Context(), doc.ID, bson.M{ |
| "$set": bson.M{ |
| "coins": doc.Coins, |
| }, |
| }) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to spend coins.") |
| return |
| } |
|
|
| stats, err := buildProgressResponse(r.Context(), coll, doc, time.Now().UTC()) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to load updated stats.") |
| return |
| } |
|
|
| jsonWrite(w, http.StatusOK, map[string]any{ |
| "user": toPublicUser(doc), |
| "stats": stats, |
| }) |
| } |
| } |
|
|
| func authenticatedUser( |
| r *http.Request, |
| coll *mongo.Collection, |
| ) (*userDoc, string, int, error) { |
| raw := readBearer(r) |
| if raw == "" { |
| return nil, "", http.StatusUnauthorized, fmt.Errorf("Not authenticated.") |
| } |
|
|
| tok, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { |
| if t.Method != jwt.SigningMethodHS256 { |
| return nil, fmt.Errorf("unexpected signing method") |
| } |
| return []byte(jwtSecret()), nil |
| }) |
| if err != nil || !tok.Valid { |
| return nil, "", http.StatusUnauthorized, fmt.Errorf("Invalid or expired session.") |
| } |
|
|
| claims, ok := tok.Claims.(*jwt.RegisteredClaims) |
| if !ok || claims.Subject == "" { |
| return nil, "", http.StatusUnauthorized, fmt.Errorf("Invalid or expired session.") |
| } |
|
|
| oid, err := primitive.ObjectIDFromHex(claims.Subject) |
| if err != nil { |
| return nil, "", http.StatusUnauthorized, fmt.Errorf("Invalid or expired session.") |
| } |
|
|
| var doc userDoc |
| err = coll.FindOne(r.Context(), bson.M{"_id": oid}).Decode(&doc) |
| if err != nil { |
| if err == mongo.ErrNoDocuments { |
| return nil, "", http.StatusUnauthorized, fmt.Errorf("User not found.") |
| } |
| return nil, "", http.StatusInternalServerError, fmt.Errorf("Could not load user.") |
| } |
|
|
| return &doc, readClientTimeZone(r), http.StatusOK, nil |
| } |
|
|
| func buildProgressResponse( |
| ctx context.Context, |
| coll *mongo.Collection, |
| currentUser *userDoc, |
| now time.Time, |
| ) (progressResponse, error) { |
| solvedQuestionIDs := normalizeQuestionIDs(currentUser.SolvedQuestionIDs) |
| dailyActivity := normalizeDailyActivity(currentUser.DailyActivity) |
| dailyActivityBreakdown := normalizeDailyActivityBreakdown(currentUser.DailyActivityBreakdown) |
| leaderboard, codingLeaderboard, err := buildLeaderboards(ctx, coll, currentUser.ID.Hex(), now) |
| if err != nil { |
| return progressResponse{}, err |
| } |
|
|
| codingSolvedCount := 0 |
| for _, id := range solvedQuestionIDs { |
| if !strings.HasPrefix(id, "sd-") && !strings.HasPrefix(id, "cs-") && !strings.HasPrefix(id, "apt-") { |
| codingSolvedCount++ |
| } |
| } |
|
|
| return progressResponse{ |
| SolvedQuestionIDs: solvedQuestionIDs, |
| DailyActivity: dailyActivity, |
| DailyActivityBreakdown: dailyActivityBreakdown, |
| SolvedCount: len(solvedQuestionIDs), |
| ActiveDays: countActiveDays(dailyActivity), |
| WeeklySolved: weeklySolvedCount(dailyActivity, currentUser.StreakTimeZone, now), |
| Leaderboard: leaderboard, |
| CodingLeaderboard: codingLeaderboard, |
| CodingSolvedCount: codingSolvedCount, |
| }, nil |
| } |
|
|
| func buildLeaderboards( |
| ctx context.Context, |
| coll *mongo.Collection, |
| currentUserID string, |
| now time.Time, |
| ) ([]progressLeaderboardUser, []progressLeaderboardUser, error) { |
| cursor, err := coll.Find(ctx, bson.M{}, options.Find().SetProjection(bson.M{ |
| "_id": 1, |
| "displayName": 1, |
| "coins": 1, |
| "currentStreak": 1, |
| "longestStreak": 1, |
| "lastActiveAt": 1, |
| "streakTimeZone": 1, |
| "solvedQuestionIds": 1, |
| "dailyActivity": 1, |
| "dailyActivityBreakdown": 1, |
| "languageStats": 1, |
| })) |
| if err != nil { |
| return nil, nil, err |
| } |
| defer cursor.Close(ctx) |
|
|
| entries := make([]progressLeaderboardUser, 0) |
| codingEntries := make([]progressLeaderboardUser, 0) |
| for cursor.Next(ctx) { |
| var doc userDoc |
| if err := cursor.Decode(&doc); err != nil { |
| return nil, nil, err |
| } |
|
|
| solvedQuestionIDs := normalizeQuestionIDs(doc.SolvedQuestionIDs) |
| dailyActivity := normalizeDailyActivity(doc.DailyActivity) |
| dailyActivityBreakdown := normalizeDailyActivityBreakdown(doc.DailyActivityBreakdown) |
| currentStreak := visibleCurrentStreak(&doc, "", now) |
| weeklySolved := weeklySolvedCount(dailyActivity, doc.StreakTimeZone, now) |
| |
| codingWeeklySolved := codingWeeklySolvedCount(dailyActivityBreakdown, doc.StreakTimeZone, now) |
| codingStreak := codingCurrentStreak(dailyActivityBreakdown, doc.StreakTimeZone, now) |
| |
| codingSolvedCount := 0 |
| for _, id := range solvedQuestionIDs { |
| if !strings.HasPrefix(id, "sd-") && !strings.HasPrefix(id, "cs-") && !strings.HasPrefix(id, "apt-") { |
| codingSolvedCount++ |
| } |
| } |
|
|
| coins := maxInt(doc.Coins, 0) |
| solvedCount := len(solvedQuestionIDs) |
| score := solvedCount*100 + weeklySolved*15 + currentStreak*30 + coins |
| codingScore := codingSolvedCount*100 |
|
|
| entries = append(entries, progressLeaderboardUser{ |
| ID: doc.ID.Hex(), |
| DisplayName: strings.TrimSpace(doc.DisplayName), |
| SolvedCount: solvedCount, |
| CurrentStreak: currentStreak, |
| WeeklySolved: weeklySolved, |
| Coins: coins, |
| Score: score, |
| IsCurrentUser: doc.ID.Hex() == currentUserID, |
| }) |
|
|
| codingEntries = append(codingEntries, progressLeaderboardUser{ |
| ID: doc.ID.Hex(), |
| DisplayName: strings.TrimSpace(doc.DisplayName), |
| SolvedCount: codingSolvedCount, |
| CurrentStreak: currentStreak, |
| CodingStreak: codingStreak, |
| WeeklySolved: codingWeeklySolved, |
| Coins: coins, |
| Score: codingScore, |
| LanguageStats: doc.LanguageStats, |
| IsCurrentUser: doc.ID.Hex() == currentUserID, |
| }) |
| } |
| if err := cursor.Err(); err != nil { |
| return nil, nil, err |
| } |
|
|
| sort.Slice(entries, func(i, j int) bool { |
| if entries[i].Score != entries[j].Score { |
| return entries[i].Score > entries[j].Score |
| } |
| if entries[i].SolvedCount != entries[j].SolvedCount { |
| return entries[i].SolvedCount > entries[j].SolvedCount |
| } |
| if entries[i].WeeklySolved != entries[j].WeeklySolved { |
| return entries[i].WeeklySolved > entries[j].WeeklySolved |
| } |
| if entries[i].Coins != entries[j].Coins { |
| return entries[i].Coins > entries[j].Coins |
| } |
| return strings.ToLower(entries[i].DisplayName) < strings.ToLower(entries[j].DisplayName) |
| }) |
|
|
| for index := range entries { |
| entries[index].Rank = index + 1 |
| if entries[index].DisplayName == "" { |
| entries[index].DisplayName = fmt.Sprintf("User %d", index+1) |
| } |
| } |
|
|
| sort.Slice(codingEntries, func(i, j int) bool { |
| if codingEntries[i].Score != codingEntries[j].Score { |
| return codingEntries[i].Score > codingEntries[j].Score |
| } |
| if codingEntries[i].SolvedCount != codingEntries[j].SolvedCount { |
| return codingEntries[i].SolvedCount > codingEntries[j].SolvedCount |
| } |
| if codingEntries[i].WeeklySolved != codingEntries[j].WeeklySolved { |
| return codingEntries[i].WeeklySolved > codingEntries[j].WeeklySolved |
| } |
| if codingEntries[i].Coins != codingEntries[j].Coins { |
| return codingEntries[i].Coins > codingEntries[j].Coins |
| } |
| return strings.ToLower(codingEntries[i].DisplayName) < strings.ToLower(codingEntries[j].DisplayName) |
| }) |
|
|
| for index := range codingEntries { |
| codingEntries[index].Rank = index + 1 |
| if codingEntries[index].DisplayName == "" { |
| codingEntries[index].DisplayName = fmt.Sprintf("User %d", index+1) |
| } |
| } |
|
|
| return entries, codingEntries, nil |
| } |
|
|
| func trimLeaderboard( |
| entries []progressLeaderboardUser, |
| currentUserID string, |
| limit int, |
| ) []progressLeaderboardUser { |
| if len(entries) <= limit { |
| return entries |
| } |
|
|
| currentIndex := -1 |
| for index, entry := range entries { |
| if entry.ID == currentUserID { |
| currentIndex = index |
| break |
| } |
| } |
|
|
| if currentIndex == -1 || currentIndex < limit { |
| return entries[:limit] |
| } |
|
|
| trimmed := make([]progressLeaderboardUser, 0, limit) |
| trimmed = append(trimmed, entries[:limit-1]...) |
| trimmed = append(trimmed, entries[currentIndex]) |
| return trimmed |
| } |
|
|
| func rewardForDifficulty(difficulty string) (int, bool) { |
| reward, ok := solveCoinRewards[strings.ToLower(strings.TrimSpace(difficulty))] |
| return reward, ok |
| } |
|
|
| func normalizeQuestionIDs(ids []string) []string { |
| seen := make(map[string]struct{}, len(ids)) |
| normalized := make([]string, 0, len(ids)) |
|
|
| for _, rawID := range ids { |
| id := strings.TrimSpace(rawID) |
| if id == "" { |
| continue |
| } |
| if _, exists := seen[id]; exists { |
| continue |
| } |
| seen[id] = struct{}{} |
| normalized = append(normalized, id) |
| } |
|
|
| return normalized |
| } |
|
|
| func containsQuestionID(ids []string, questionID string) bool { |
| for _, id := range ids { |
| if id == questionID { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| func mergeQuestionIDs(existing, incoming []string) []string { |
| return normalizeQuestionIDs(append(normalizeQuestionIDs(existing), incoming...)) |
| } |
|
|
| func normalizeDailyActivity(activity map[string]int) map[string]int { |
| if len(activity) == 0 { |
| return map[string]int{} |
| } |
|
|
| normalized := make(map[string]int, len(activity)) |
| for key, count := range activity { |
| trimmedKey := strings.TrimSpace(key) |
| if trimmedKey == "" || count <= 0 { |
| continue |
| } |
| normalized[trimmedKey] = count |
| } |
|
|
| return normalized |
| } |
|
|
| func normalizeDailyActivityBreakdown(breakdown map[string]map[string]int) map[string]map[string]int { |
| if len(breakdown) == 0 { |
| return map[string]map[string]int{} |
| } |
|
|
| normalized := make(map[string]map[string]int, len(breakdown)) |
| for dateKey, sectionMap := range breakdown { |
| trimmedDateKey := strings.TrimSpace(dateKey) |
| if trimmedDateKey == "" || len(sectionMap) == 0 { |
| continue |
| } |
| |
| normalizedMap := make(map[string]int, len(sectionMap)) |
| for sectionKey, count := range sectionMap { |
| trimmedSectionKey := strings.TrimSpace(sectionKey) |
| if trimmedSectionKey == "" || count <= 0 { |
| continue |
| } |
| normalizedMap[trimmedSectionKey] = count |
| } |
| |
| if len(normalizedMap) > 0 { |
| normalized[trimmedDateKey] = normalizedMap |
| } |
| } |
|
|
| return normalized |
| } |
|
|
| func mergeDailyActivity(existing, incoming map[string]int) map[string]int { |
| merged := normalizeDailyActivity(existing) |
| for key, count := range normalizeDailyActivity(incoming) { |
| if merged[key] < count { |
| merged[key] = count |
| } |
| } |
| return merged |
| } |
|
|
| func countActiveDays(activity map[string]int) int { |
| activeDays := 0 |
| for _, count := range activity { |
| if count > 0 { |
| activeDays++ |
| } |
| } |
| return activeDays |
| } |
|
|
| func weeklySolvedCount(activity map[string]int, timeZone string, now time.Time) int { |
| if len(activity) == 0 { |
| return 0 |
| } |
|
|
| loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) |
| localNow := now.In(loc) |
| total := 0 |
|
|
| for offset := 0; offset < 7; offset++ { |
| currentDay := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 12, 0, 0, 0, loc).AddDate(0, 0, -offset) |
| total += activity[currentDay.Format("2006-01-02")] |
| } |
|
|
| return total |
| } |
|
|
| func codingWeeklySolvedCount(breakdown map[string]map[string]int, timeZone string, now time.Time) int { |
| if len(breakdown) == 0 { |
| return 0 |
| } |
|
|
| loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) |
| localNow := now.In(loc) |
| total := 0 |
|
|
| for offset := 0; offset < 7; offset++ { |
| currentDay := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 12, 0, 0, 0, loc).AddDate(0, 0, -offset) |
| dayKey := currentDay.Format("2006-01-02") |
| if sectionMap, ok := breakdown[dayKey]; ok { |
| total += sectionMap["coding"] |
| } |
| } |
|
|
| return total |
| } |
|
|
| func codingCurrentStreak(breakdown map[string]map[string]int, timeZone string, now time.Time) int { |
| if len(breakdown) == 0 { |
| return 0 |
| } |
| loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) |
| localNow := now.In(loc) |
| streak := 0 |
| |
| todayKey := localNow.Format("2006-01-02") |
| yesterdayKey := localNow.AddDate(0, 0, -1).Format("2006-01-02") |
| |
| todayCoding := 0 |
| if breakdown[todayKey] != nil { |
| todayCoding = breakdown[todayKey]["coding"] |
| } |
| yesterdayCoding := 0 |
| if breakdown[yesterdayKey] != nil { |
| yesterdayCoding = breakdown[yesterdayKey]["coding"] |
| } |
| |
| if todayCoding == 0 && yesterdayCoding == 0 { |
| return 0 |
| } |
| |
| currentDay := localNow |
| if todayCoding == 0 { |
| currentDay = localNow.AddDate(0, 0, -1) |
| } |
| |
| for { |
| dayKey := currentDay.Format("2006-01-02") |
| codingCount := 0 |
| if breakdown[dayKey] != nil { |
| codingCount = breakdown[dayKey]["coding"] |
| } |
| if codingCount > 0 { |
| streak++ |
| currentDay = currentDay.AddDate(0, 0, -1) |
| } else { |
| break |
| } |
| } |
| |
| return streak |
| } |
|
|
| func activityDateKey(now time.Time, timeZone string) string { |
| loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) |
| return now.In(loc).Format("2006-01-02") |
| } |
|
|
| func streakSnapshotFromActivity(activity map[string]int, timeZone string) streakSnapshot { |
| if len(activity) == 0 { |
| return streakSnapshot{} |
| } |
|
|
| loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) |
| days := make([]time.Time, 0, len(activity)) |
| for key, count := range activity { |
| if count <= 0 { |
| continue |
| } |
| parsed, err := time.ParseInLocation("2006-01-02", key, loc) |
| if err != nil { |
| continue |
| } |
| days = append(days, parsed) |
| } |
|
|
| if len(days) == 0 { |
| return streakSnapshot{} |
| } |
|
|
| sort.Slice(days, func(i, j int) bool { |
| return days[i].Before(days[j]) |
| }) |
|
|
| longestStreak := 1 |
| currentRun := 1 |
| for index := 1; index < len(days); index++ { |
| dayDelta := int(days[index].Sub(days[index-1]).Hours() / 24) |
| switch { |
| case dayDelta <= 0: |
| continue |
| case dayDelta == 1: |
| currentRun++ |
| default: |
| currentRun = 1 |
| } |
| if longestStreak < currentRun { |
| longestStreak = currentRun |
| } |
| } |
|
|
| currentStreak := 1 |
| for index := len(days) - 1; index > 0; index-- { |
| dayDelta := int(days[index].Sub(days[index-1]).Hours() / 24) |
| if dayDelta != 1 { |
| break |
| } |
| currentStreak++ |
| } |
|
|
| lastActiveAt := time.Date(days[len(days)-1].Year(), days[len(days)-1].Month(), days[len(days)-1].Day(), 12, 0, 0, 0, loc).UTC() |
| return streakSnapshot{ |
| CurrentStreak: currentStreak, |
| LongestStreak: longestStreak, |
| LastActiveAt: lastActiveAt, |
| } |
| } |
|
|
| type coinTransactionDoc struct { |
| ID primitive.ObjectID `bson:"_id" json:"id"` |
| UserID string `bson:"userId" json:"userId"` |
| Amount int `bson:"amount" json:"amount"` |
| Source string `bson:"source" json:"source"` |
| Category string `bson:"category" json:"category"` |
| CreatedAt time.Time `bson:"createdAt" json:"createdAt"` |
| } |
|
|
| func recordCoinTransaction(ctx context.Context, coll *mongo.Collection, userID primitive.ObjectID, amount int, source string, category string) error { |
| doc := coinTransactionDoc{ |
| ID: primitive.NewObjectID(), |
| UserID: userID.Hex(), |
| Amount: amount, |
| Source: source, |
| Category: category, |
| CreatedAt: time.Now().UTC(), |
| } |
| _, err := coll.Database().Collection("coin_transactions").InsertOne(ctx, doc) |
| return err |
| } |
|
|
| func handleGetCoinHistory(coll *mongo.Collection) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != http.MethodGet { |
| jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") |
| return |
| } |
|
|
| doc, _, statusCode, err := authenticatedUser(r, coll) |
| if err != nil { |
| jsonError(w, statusCode, err.Error()) |
| return |
| } |
|
|
| cursor, err := coll.Database().Collection("coin_transactions").Find(r.Context(), bson.M{"userId": doc.ID.Hex()}, options.Find().SetSort(bson.M{"createdAt": -1})) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch coin history.") |
| return |
| } |
| defer cursor.Close(r.Context()) |
|
|
| var transactions []coinTransactionDoc |
| if err := cursor.All(r.Context(), &transactions); err != nil { |
| jsonError(w, http.StatusInternalServerError, "Failed to fetch coin history.") |
| return |
| } |
|
|
| if transactions == nil { |
| transactions = []coinTransactionDoc{} |
| } |
|
|
| sum := 0 |
| for _, tx := range transactions { |
| sum += tx.Amount |
| } |
|
|
| if doc.Coins > sum { |
| legacyAmount := doc.Coins - sum |
| |
| createdAt := doc.CreatedAt |
| if createdAt.IsZero() { |
| createdAt = time.Now().UTC() |
| } |
| |
| transactions = append(transactions, coinTransactionDoc{ |
| ID: primitive.NewObjectID(), |
| UserID: doc.ID.Hex(), |
| Amount: legacyAmount, |
| Source: "Initial Balance", |
| Category: "bonus", |
| CreatedAt: createdAt, |
| }) |
| } |
|
|
| jsonWrite(w, http.StatusOK, transactions) |
| } |
| } |
|
|