diff --git a/backend/controllers/auth.go b/backend/controllers/auth.go index 0e77baf..2f9a47d 100644 --- a/backend/controllers/auth.go +++ b/backend/controllers/auth.go @@ -75,7 +75,7 @@ func GoogleLogin(ctx *gin.Context) { Email: email, Password: "", // No password for Google users Nickname: nickname, - EloRating: 1200, + Rating: 1200, IsVerified: true, // Google-verified emails are trusted CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -144,7 +144,7 @@ func SignUp(ctx *gin.Context) { Email: request.Email, Password: string(hashedPassword), Nickname: utils.ExtractNameFromEmail(request.Email), - EloRating: 1200, + Rating: 1200, IsVerified: false, VerificationCode: verificationCode, CreatedAt: time.Now(), diff --git a/backend/controllers/leaderboard.go b/backend/controllers/leaderboard.go index 26414f2..3f42c0d 100644 --- a/backend/controllers/leaderboard.go +++ b/backend/controllers/leaderboard.go @@ -46,7 +46,7 @@ func GetLeaderboard(c *gin.Context) { return } - // Query users sorted by EloRating (descending) + // Query users sorted by Rating (descending) collection := db.MongoDatabase.Collection("users") findOptions := options.Find().SetSort(bson.D{{"eloRating", -1}}) cursor, err := collection.Find(c, bson.M{}, findOptions) @@ -83,7 +83,7 @@ func GetLeaderboard(c *gin.Context) { ID: user.ID.Hex(), Rank: i + 1, Name: name, - Score: user.EloRating, + Score: int(user.Rating), AvatarURL: avatarURL, CurrentUser: isCurrentUser, }) diff --git a/backend/controllers/profile_controller.go b/backend/controllers/profile_controller.go index 2a69cde..71ad332 100644 --- a/backend/controllers/profile_controller.go +++ b/backend/controllers/profile_controller.go @@ -2,6 +2,8 @@ package controllers import ( "context" + "fmt" + "math" "net/http" "strings" "time" @@ -12,12 +14,30 @@ import ( "github.com/gin-gonic/gin" "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" - - "fmt" ) +// Calculate new Elo ratings using float64 +func calculateEloRating(ratingA, ratingB float64, scoreA float64) (newRatingA, newRatingB float64) { + const K = 32.0 + expectedA := 1.0 / (1.0 + math.Pow(10, (ratingB-ratingA)/400.0)) + expectedB := 1.0 - expectedA + scoreB := 1.0 - scoreA + + newRatingA = ratingA + K*(scoreA-expectedA) + newRatingB = ratingB + K*(scoreB-expectedB) + return newRatingA, newRatingB +} + +func extractNameFromEmail(email string) string { + for i, char := range email { + if char == '@' { + return email[:i] + } + } + return email +} + func GetProfile(c *gin.Context) { email := c.GetString("email") @@ -29,131 +49,98 @@ func GetProfile(c *gin.Context) { dbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - // Fetch user profile var user models.User err := db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"email": email}).Decode(&user) if err != nil { - if err == mongo.ErrNoDocuments { - c.JSON(http.StatusNotFound, gin.H{"error": "Not found", "message": "User not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error", "message": fmt.Sprintf("Failed to fetch user: %v", err)}) - } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error", "message": fmt.Sprintf("Failed to fetch user: %v", err)}) return } - // Avatar fallback - profileAvatarURL := user.AvatarURL displayName := user.DisplayName - if profileAvatarURL == "" { - if displayName == "" { - displayName = extractNameFromEmail(user.Email) - } - profileAvatarURL = "https://api.dicebear.com/9.x/adventurer/svg?seed=" + displayName - } if displayName == "" { - displayName = "Steve" + displayName = extractNameFromEmail(user.Email) + } + avatar := user.AvatarURL + if avatar == "" { + avatar = "https://api.dicebear.com/9.x/adventurer/svg?seed=" + displayName } - // Fetch top leaderboard - leaderboardCursor, err := db.MongoDatabase.Collection("users").Find( + // Leaderboard top 5 + cursor, err := db.MongoDatabase.Collection("users").Find( dbCtx, bson.M{}, options.Find().SetSort(bson.D{{"eloRating", -1}}).SetLimit(5), ) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error", "message": fmt.Sprintf("Failed to fetch leaderboard: %v", err)}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error", "message": "Failed to fetch leaderboard"}) return } - defer leaderboardCursor.Close(dbCtx) + defer cursor.Close(dbCtx) - var leaderboard []struct { + type LeaderboardEntry struct { Rank int `json:"rank"` Name string `json:"name"` Score int `json:"score"` AvatarUrl string `json:"avatarUrl"` CurrentUser bool `json:"currentUser"` } + var leaderboard []LeaderboardEntry rank := 1 - for leaderboardCursor.Next(dbCtx) { - var lbUser models.User - if err := leaderboardCursor.Decode(&lbUser); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error", "message": fmt.Sprintf("Failed to decode leaderboard user: %v", err)}) - return + for cursor.Next(dbCtx) { + var u models.User + if err := cursor.Decode(&u); err != nil { + continue } - lbName := lbUser.DisplayName - if lbName == "" { - lbName = extractNameFromEmail(lbUser.Email) + name := u.DisplayName + if name == "" { + name = extractNameFromEmail(u.Email) } - lbAvatar := lbUser.AvatarURL - if lbAvatar == "" { - lbAvatar = "https://api.dicebear.com/9.x/adventurer/svg?seed=" + lbName + url := u.AvatarURL + if url == "" { + url = "https://api.dicebear.com/9.x/adventurer/svg?seed=" + name } - leaderboard = append(leaderboard, struct { - Rank int `json:"rank"` - Name string `json:"name"` - Score int `json:"score"` - AvatarUrl string `json:"avatarUrl"` - CurrentUser bool `json:"currentUser"` - }{rank, lbName, lbUser.EloRating, lbAvatar, lbUser.Email == email}) + leaderboard = append(leaderboard, LeaderboardEntry{ + Rank: rank, + Name: name, + Score: int(u.Rating), + AvatarUrl: url, + CurrentUser: u.Email == email, + }) rank++ } - // Fetch user's debate history - debatesCursor, err := db.MongoDatabase.Collection("debates").Find( + // Debate history + debateCursor, err := db.MongoDatabase.Collection("debates").Find( dbCtx, bson.M{"email": email}, options.Find().SetSort(bson.D{{"date", 1}}), ) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error", "message": fmt.Sprintf("Failed to fetch debates: %v", err)}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch debates"}) return } - defer debatesCursor.Close(dbCtx) + defer debateCursor.Close(dbCtx) type DebateDoc struct { - Topic string `bson:"topic"` - Result string `bson:"result"` - EloChange int `bson:"eloChange"` - EloRating int `bson:"eloRating"` - Date time.Time `bson:"date"` - } - - var debateDocs []DebateDoc - if err := debatesCursor.All(dbCtx, &debateDocs); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error", "message": fmt.Sprintf("Failed to decode debates: %v", err)}) - return + Topic string `bson:"topic"` + Result string `bson:"result"` + RatingChange float64 `bson:"eloChange"` + Rating float64 `bson:"eloRating"` + Date time.Time `bson:"date"` } - // Process debates var wins, losses, draws int - var eloHistory []struct { - Elo int `json:"elo"` - Date time.Time `json:"date"` - } - var debateHistory []struct { - Topic string `json:"topic"` - Result string `json:"result"` - EloChange int `json:"eloChange"` - } + var eloHistory []gin.H + var debateHistory []gin.H - for _, doc := range debateDocs { - eloHistory = append(eloHistory, struct { - Elo int `json:"elo"` - Date time.Time `json:"date"` - }{ - Elo: doc.EloRating, - Date: doc.Date, - }) + for debateCursor.Next(dbCtx) { + var doc DebateDoc + if err := debateCursor.Decode(&doc); err != nil { + continue + } - debateHistory = append(debateHistory, struct { - Topic string `json:"topic"` - Result string `json:"result"` - EloChange int `json:"eloChange"` - }{ - Topic: doc.Topic, - Result: doc.Result, - EloChange: doc.EloChange, - }) + eloHistory = append(eloHistory, gin.H{"elo": int(doc.Rating), "date": doc.Date}) + debateHistory = append(debateHistory, gin.H{"topic": doc.Topic, "result": doc.Result, "eloChange": int(doc.RatingChange)}) switch doc.Result { case "win": @@ -165,17 +152,16 @@ func GetProfile(c *gin.Context) { } } - // Final response - response := gin.H{ + c.JSON(http.StatusOK, gin.H{ "profile": gin.H{ "displayName": displayName, "email": user.Email, "bio": user.Bio, - "eloRating": user.EloRating, + "eloRating": int(user.Rating), "twitter": user.Twitter, "instagram": user.Instagram, "linkedin": user.LinkedIn, - "avatarUrl": profileAvatarURL, + "avatarUrl": avatar, }, "leaderboard": leaderboard, "stats": gin.H{ @@ -185,16 +171,13 @@ func GetProfile(c *gin.Context) { "eloHistory": eloHistory, "debateHistory": debateHistory, }, - } - c.JSON(http.StatusOK, response) + }) } - - func UpdateProfile(ctx *gin.Context) { email := ctx.GetString("email") if email == "" { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized", "message": "Missing user email in context"}) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } @@ -204,48 +187,34 @@ func UpdateProfile(ctx *gin.Context) { Twitter string `json:"twitter"` Instagram string `json:"instagram"` LinkedIn string `json:"linkedin"` + AvatarURL string `json:"avatarUrl"` // Added AvatarURL } if err := ctx.ShouldBindJSON(&updateData); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "message": err.Error()}) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid body"}) return } - // Trim whitespace from input fields - updateData.DisplayName = strings.TrimSpace(updateData.DisplayName) - updateData.Bio = strings.TrimSpace(updateData.Bio) - updateData.Twitter = strings.TrimSpace(updateData.Twitter) - updateData.Instagram = strings.TrimSpace(updateData.Instagram) - updateData.LinkedIn = strings.TrimSpace(updateData.LinkedIn) + update := bson.M{"$set": bson.M{ + "displayName": strings.TrimSpace(updateData.DisplayName), + "bio": strings.TrimSpace(updateData.Bio), + "twitter": strings.TrimSpace(updateData.Twitter), + "instagram": strings.TrimSpace(updateData.Instagram), + "linkedin": strings.TrimSpace(updateData.LinkedIn), + "avatarUrl": strings.TrimSpace(updateData.AvatarURL), // Added avatarUrl + "updatedAt": time.Now(), + }} dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - filter := bson.M{"email": email} - update := bson.M{ - "$set": bson.M{ - "displayName": updateData.DisplayName, - "bio": updateData.Bio, - "twitter": updateData.Twitter, - "instagram": updateData.Instagram, - "linkedin": updateData.LinkedIn, - "updatedAt": time.Now(), - }, - } - result, err := db.MongoDatabase.Collection("users").UpdateOne(dbCtx, filter, update) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error", "message": "Failed to update profile"}) + result, err := db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"email": email}, update) + if err != nil || result.MatchedCount == 0 { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"}) return } - - if result.MatchedCount == 0 { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found", "message": "User not found"}) - return - } - ctx.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"}) } -// UpdateEloAfterDebate updates Elo ratings for winner and loser func UpdateEloAfterDebate(ctx *gin.Context) { var req struct { WinnerID string `json:"winnerId"` @@ -253,93 +222,50 @@ func UpdateEloAfterDebate(ctx *gin.Context) { Topic string `json:"topic"` } if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "message": err.Error()}) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - winnerObjID, err := primitive.ObjectIDFromHex(req.WinnerID) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid winnerId"}) - return - } - loserObjID, err := primitive.ObjectIDFromHex(req.LoserID) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loserId"}) - return - } + winnerID, _ := primitive.ObjectIDFromHex(req.WinnerID) + loserID, _ := primitive.ObjectIDFromHex(req.LoserID) var winner, loser models.User - if err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"_id": winnerObjID}).Decode(&winner); err != nil { - if err == mongo.ErrNoDocuments { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Winner not found"}) - } else { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching winner from DB"}) - } - return - } + _ = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"_id": winnerID}).Decode(&winner) + _ = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"_id": loserID}).Decode(&loser) - if err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"_id": loserObjID}).Decode(&loser); err != nil { - if err == mongo.ErrNoDocuments { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Loser not found"}) - } else { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching loser from DB"}) - } - return - } + newWinnerElo, newLoserElo := calculateEloRating(winner.Rating, loser.Rating, 1.0) - // Calculate new Elo ratings - newWinnerElo, newLoserElo := calculateEloRating(winner.EloRating, loser.EloRating, 1.0) - winnerEloChange := newWinnerElo - winner.EloRating - loserEloChange := newLoserElo - loser.EloRating + winnerChange := newWinnerElo - winner.Rating + loserChange := newLoserElo - loser.Rating - // Update user Elo ratings - _, err = db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"_id": winnerObjID}, bson.M{"$set": bson.M{"eloRating": newWinnerElo}}) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) - return - } + // Update users + db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"_id": winnerID}, bson.M{"$set": bson.M{"eloRating": newWinnerElo}}) + db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"_id": loserID}, bson.M{"$set": bson.M{"eloRating": newLoserElo}}) - _, err = db.MongoDatabase.Collection("users").UpdateOne(dbCtx, bson.M{"_id": loserObjID}, bson.M{"$set": bson.M{"eloRating": newLoserElo}}) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) - return - } - - // Record debate results + // Log debates now := time.Now() - winnerDebate := models.Debate{ - Email: winner.Email, - Topic: req.Topic, - Result: "win", - EloChange: winnerEloChange, - Date: now, - } - loserDebate := models.Debate{ - Email: loser.Email, - Topic: req.Topic, - Result: "loss", - EloChange: loserEloChange, - Date: now, - } - - db.MongoDatabase.Collection("debates").InsertOne(dbCtx, winnerDebate) - db.MongoDatabase.Collection("debates").InsertOne(dbCtx, loserDebate) + db.MongoDatabase.Collection("debates").InsertOne(dbCtx, bson.M{ + "email": winner.Email, + "topic": req.Topic, + "result": "win", + "eloChange": winnerChange, + "eloRating": newWinnerElo, + "date": now, + }) + db.MongoDatabase.Collection("debates").InsertOne(dbCtx, bson.M{ + "email": loser.Email, + "topic": req.Topic, + "result": "loss", + "eloChange": loserChange, + "eloRating": newLoserElo, + "date": now, + }) ctx.JSON(http.StatusOK, gin.H{ - "winnerNewElo": newWinnerElo, - "loserNewElo": newLoserElo, + "winnerNewElo": int(newWinnerElo), + "loserNewElo": int(newLoserElo), }) } - -// extractNameFromEmail extracts the name from an email address -func extractNameFromEmail(email string) string { - for i, char := range email { - if char == '@' { - return email[:i] - } - } - return email -} \ No newline at end of file diff --git a/backend/models/debate.go b/backend/models/debate.go index b0dcf86..88c4d6c 100644 --- a/backend/models/debate.go +++ b/backend/models/debate.go @@ -10,7 +10,7 @@ import ( type Debate struct { ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"` UserID primitive.ObjectID `bson:"userId" json:"userId"` - UserEmail string `bson:"userEmail" json:"userEmail"` + Email string `bson:"email" json:"email"` OpponentID primitive.ObjectID `bson:"opponentId,omitempty" json:"opponentId,omitempty"` OpponentEmail string `bson:"opponentEmail,omitempty" json:"opponentEmail,omitempty"` Topic string `bson:"topic" json:"topic"` diff --git a/backend/rating/glicko2.go b/backend/rating/glicko2.go index 79aa19f..d19528b 100644 --- a/backend/rating/glicko2.go +++ b/backend/rating/glicko2.go @@ -49,7 +49,7 @@ func DefaultConfig() *Config { // Glicko2 implements the rating system type Glicko2 struct { - config *Config + Config *Config } // New creates a Glicko-2 rating system with configuration @@ -57,15 +57,15 @@ func New(config *Config) *Glicko2 { if config == nil { config = DefaultConfig() } - return &Glicko2{config: config} + return &Glicko2{Config: config} } // NewPlayer creates a new player with initial ratings func (g *Glicko2) NewPlayer() *Player { return &Player{ - Rating: g.config.InitialRating, - RD: g.config.InitialRD, - Volatility: g.config.InitialVol, + Rating: g.Config.InitialRating, + RD: g.Config.InitialRD, + Volatility: g.Config.InitialVol, LastUpdate: time.Now(), } } @@ -106,24 +106,24 @@ func (g *Glicko2) updateTimeRD(p *Player, currentTime time.Time) { } secPassed := currentTime.Sub(p.LastUpdate).Seconds() - periods := secPassed / g.config.RatingPeriodSec + periods := secPassed / g.Config.RatingPeriodSec if periods > 0 { rdSq := p.RD * p.RD volSq := p.Volatility * p.Volatility newRD := math.Sqrt(rdSq + volSq*periods) - p.RD = math.Min(newRD, g.config.MaxRD) + p.RD = math.Min(newRD, g.Config.MaxRD) } } // scaleToGlicko2 converts to internal Glicko-2 scale func (g *Glicko2) scaleToGlicko2(rating, rd float64) (float64, float64) { - return (rating - g.config.InitialRating) / scale, rd / scale + return (rating - g.Config.InitialRating) / scale, rd / scale } // scaleFromGlicko2 converts from internal scale to original func (g *Glicko2) scaleFromGlicko2(mu, phi float64) (float64, float64) { - return mu*scale + g.config.InitialRating, phi * scale + return mu*scale + g.Config.InitialRating, phi * scale } // calculateUpdate performs core rating calculations @@ -168,7 +168,7 @@ func (g *Glicko2) updateVolatility(sigma, phi, v, delta float64) float64 { ex := math.Exp(x) num := ex * (deltaSq - phiSq - v - ex) denom := 2 * math.Pow(phiSq+v+ex, 2) - return num/denom - (x-a)/(g.config.Tau*g.config.Tau) + return num/denom - (x-a)/(g.Config.Tau*g.Config.Tau) } // Newton-Raphson iteration diff --git a/backend/routes/debate.go b/backend/routes/debate.go index 3b6d11f..dec2447 100644 --- a/backend/routes/debate.go +++ b/backend/routes/debate.go @@ -1,11 +1,12 @@ package routes import ( + "context" "net/http" "time" - "arguehub/models" "arguehub/services" + "arguehub/db" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson/primitive" @@ -56,7 +57,7 @@ func UpdateRatingAfterDebateRouteHandler(c *gin.Context) { debate.Result = request.Outcome // Save debate record - collection := db.GetCollection("debates") + collection := db.MongoDatabase.Collection("debates") _, err = collection.InsertOne(context.Background(), debate) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save debate record"}) diff --git a/backend/routes/leaderboard.go b/backend/routes/leaderboard.go index d5483df..94f086e 100644 --- a/backend/routes/leaderboard.go +++ b/backend/routes/leaderboard.go @@ -1,17 +1,9 @@ package routes import ( - "context" - "net/http" - - "arguehub/db" - "arguehub/models" "arguehub/controllers" "github.com/gin-gonic/gin" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" ) func GetLeaderboardRouteHandler(c *gin.Context) { @@ -19,46 +11,3 @@ func GetLeaderboardRouteHandler(c *gin.Context) { } -// GetLeaderboardRouteHandler fetches the leaderboard -func GetLeaderboardRouteHandler(c *gin.Context) { - collection := db.GetCollection("users") - - // Sort by rating descending, then by RD ascending (more certain ratings first) - opts := options.Find().SetSort(bson.D{ - {Key: "rating", Value: -1}, - {Key: "rd", Value: 1}, - }).SetLimit(100) - - cursor, err := collection.Find(context.Background(), bson.M{}, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch leaderboard"}) - return - } - defer cursor.Close(context.Background()) - - var users []models.User - if err = cursor.All(context.Background(), &users); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode leaderboard"}) - return - } - - // Return minimal data for leaderboard - type LeaderboardEntry struct { - DisplayName string `json:"displayName"` - Rating float64 `json:"rating"` - RD float64 `json:"rd"` - AvatarURL string `json:"avatarUrl"` - } - - var entries []LeaderboardEntry - for _, user := range users { - entries = append(entries, LeaderboardEntry{ - DisplayName: user.DisplayName, - Rating: user.Rating, - RD: user.RD, - AvatarURL: user.AvatarURL, - }) - } - - c.JSON(http.StatusOK, entries) -} \ No newline at end of file diff --git a/backend/routes/rooms.go b/backend/routes/rooms.go index 1022459..fb8466e 100644 --- a/backend/routes/rooms.go +++ b/backend/routes/rooms.go @@ -64,7 +64,7 @@ func CreateRoomHandler(c *gin.Context) { ID string `bson:"_id"` Email string `bson:"email"` DisplayName string `bson:"displayName"` - EloRating int `bson:"eloRating"` + Rating int `bson:"eloRating"` } err := userCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user) @@ -77,7 +77,7 @@ func CreateRoomHandler(c *gin.Context) { creatorParticipant := Participant{ ID: user.ID, Username: user.DisplayName, - Elo: user.EloRating, + Elo: user.Rating, } roomID := generateRoomID() diff --git a/backend/services/rating_service.go b/backend/services/rating_service.go index 6f80f5c..998b9f7 100644 --- a/backend/services/rating_service.go +++ b/backend/services/rating_service.go @@ -2,16 +2,15 @@ package services import ( "context" - "log" "time" "arguehub/config" "arguehub/models" "arguehub/rating" + "arguehub/db" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" - "go.mongodb.org/mongo-driver/mongo" ) var ratingSystem *rating.Glicko2 @@ -20,6 +19,11 @@ func InitRatingService(cfg *config.Config) { ratingSystem = rating.New(nil) } +func GetRatingSystem() *rating.Glicko2 { + return ratingSystem +} + + // UpdateRatings updates ratings after a debate func UpdateRatings(userID, opponentID primitive.ObjectID, outcome float64, debateTime time.Time) (*models.Debate, error) { // Get both players from database @@ -51,8 +55,7 @@ func UpdateRatings(userID, opponentID primitive.ObjectID, outcome float64, debat // Save pre-rating state for history preUserRating := user.Rating preUserRD := user.RD - preOpponentRating := opponent.Rating - preOpponentRD := opponent.RD + // Update ratings ratingSystem.UpdateMatch(userPlayer, opponentPlayer, outcome, debateTime) @@ -60,7 +63,7 @@ func UpdateRatings(userID, opponentID primitive.ObjectID, outcome float64, debat // Create debate record debate := &models.Debate{ UserID: userID, - UserEmail: user.Email, + Email: user.Email, OpponentID: opponentID, OpponentEmail: opponent.Email, Date: debateTime, @@ -88,14 +91,14 @@ func UpdateRatings(userID, opponentID primitive.ObjectID, outcome float64, debat // Helper function to get user by ID func getUserByID(id primitive.ObjectID) (*models.User, error) { var user models.User - collection := db.GetCollection("users") + collection := db.MongoDatabase.Collection("users") err := collection.FindOne(context.Background(), bson.M{"_id": id}).Decode(&user) return &user, err } // Helper function to update user rating func updateUserRating(id primitive.ObjectID, player *rating.Player) error { - collection := db.GetCollection("users") + collection := db.MongoDatabase.Collection("users") update := bson.M{ "$set": bson.M{ "rating": player.Rating, diff --git a/backend/utils/auth.go b/backend/utils/auth.go index 870a450..ca16892 100644 --- a/backend/utils/auth.go +++ b/backend/utils/auth.go @@ -9,7 +9,6 @@ import ( "log" "regexp" "time" - "arguehub/config" "crypto/sha256" diff --git a/backend/utils/debate.go b/backend/utils/debate.go index 99868a7..877821f 100644 --- a/backend/utils/debate.go +++ b/backend/utils/debate.go @@ -27,35 +27,35 @@ func SeedDebateData() { Email: "irishittiwari@gmail.com", Topic: "Global Warming", Result: "win", - EloChange: 12, + RatingChange: 12, Date: time.Now().Add(-time.Hour * 24 * 30), }, { Email: "irishittiwari@gmail.com", Topic: "Universal Healthcare", Result: "loss", - EloChange: -5, + RatingChange: -5, Date: time.Now().Add(-time.Hour * 24 * 20), }, { Email: "irishittiwari@gmail.com", Topic: "Social Media Regulation", Result: "draw", - EloChange: 0, + RatingChange: 0, Date: time.Now().Add(-time.Hour * 24 * 10), }, { Email: "irishittiwari@gmail.com", Topic: "Renewable Energy", Result: "win", - EloChange: 10, + RatingChange: 10, Date: time.Now().Add(-time.Hour * 24 * 5), }, { Email: "irishittiwari@gmail.com", Topic: "Space Exploration", Result: "loss", - EloChange: -7, + RatingChange: -7, Date: time.Now().Add(-time.Hour * 24 * 2), }, } diff --git a/backend/utils/populate.go b/backend/utils/populate.go index bea46a2..96ea120 100644 --- a/backend/utils/populate.go +++ b/backend/utils/populate.go @@ -5,47 +5,51 @@ import ( "log" "time" - "arguehub/config" "arguehub/db" "arguehub/models" "arguehub/services" + "arguehub/config" "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" ) // PopulateTestUsers creates test users with Glicko-2 ratings func PopulateTestUsers() { - collection := db.GetCollection("users") + cfg, err := config.LoadConfig("./config/config.prod.yml") + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + collection := db.MongoDatabase.Collection("users") count, _ := collection.CountDocuments(context.Background(), bson.M{}) if count > 0 { return } - // Initialize rating system - ratingSystem := services.GetRatingSystem() + // Initialize rating system (no return value) + services.InitRatingService(cfg) + ratingSystem := services.GetRatingSystem() // <- add a getter in services package testUsers := []models.User{ { Email: "user1@example.com", DisplayName: "DebateMaster", Bio: "Experienced debater", - Rating: ratingSystem.config.InitialRating, - RD: ratingSystem.config.InitialRD, - Volatility: ratingSystem.config.InitialVol, + Rating: ratingSystem.Config.InitialRating, + RD: ratingSystem.Config.InitialRD, + Volatility: ratingSystem.Config.InitialVol, CreatedAt: time.Now(), }, { Email: "user2@example.com", DisplayName: "LogicLord", Bio: "Lover of logical arguments", - Rating: ratingSystem.config.InitialRating, - RD: ratingSystem.config.InitialRD, - Volatility: ratingSystem.config.InitialVol, + Rating: ratingSystem.Config.InitialRating, + RD: ratingSystem.Config.InitialRD, + Volatility: ratingSystem.Config.InitialVol, CreatedAt: time.Now(), }, - // Add more test users as needed } var documents []interface{} @@ -53,10 +57,10 @@ func PopulateTestUsers() { documents = append(documents, user) } - _, err := collection.InsertMany(context.Background(), documents) + _, err = collection.InsertMany(context.Background(), documents) if err != nil { log.Printf("Failed to insert test users: %v", err) } else { log.Println("Inserted test users") } -} \ No newline at end of file +} diff --git a/backend/utils/user.go b/backend/utils/user.go index d41dede..afa1aea 100644 --- a/backend/utils/user.go +++ b/backend/utils/user.go @@ -11,7 +11,7 @@ import ( ) // PopulateTestUsers inserts sample users into the database -func PopulateTestUsers() { +func PopulateTestUsers1() { collection := db.MongoDatabase.Collection("users") // Define sample users @@ -21,7 +21,7 @@ func PopulateTestUsers() { Email: "alice@example.com", DisplayName: "Alice Johnson", Bio: "Debate enthusiast", - EloRating: 2500, + Rating: 2500, CreatedAt: time.Now(), }, { @@ -29,7 +29,7 @@ func PopulateTestUsers() { Email: "bob@example.com", DisplayName: "Bob Smith", Bio: "Argument master", - EloRating: 2400, + Rating: 2400, CreatedAt: time.Now(), }, { @@ -37,7 +37,7 @@ func PopulateTestUsers() { Email: "carol@example.com", DisplayName: "Carol Davis", Bio: "Wordsmith", - EloRating: 2350, + Rating: 2350, CreatedAt: time.Now(), }, } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f4f0871..cedaf47 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", @@ -23576,6 +23577,212 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", + "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -52482,6 +52689,102 @@ "@radix-ui/react-primitive": "2.0.0" } }, + "@radix-ui/react-slider": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", + "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", + "requires": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "dependencies": { + "@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==" + }, + "@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "requires": {} + }, + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "requires": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "requires": {} + }, + "@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "requires": {} + }, + "@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.1" + } + } + } + }, "@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0d941b9..a8f23cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", diff --git a/frontend/src/Pages/BotSelection.tsx b/frontend/src/Pages/BotSelection.tsx index 2e630ec..4285d57 100644 --- a/frontend/src/Pages/BotSelection.tsx +++ b/frontend/src/Pages/BotSelection.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -11,6 +11,8 @@ import { } from "@/components/ui/select"; import { Separator } from "../components/ui/separator"; import { createDebate } from "@/services/vsbot"; +import { getProfile } from "@/services/profileService"; +import { getAuthToken } from "@/utils/auth"; // Bot type definition interface Bot { @@ -22,7 +24,7 @@ interface Bot { rating: number; } -// Bot definitions (now combined) +// Bot definitions const allBots: Bot[] = [ // Classic bots { @@ -208,6 +210,13 @@ const BotSelection: React.FC = () => { const [phaseTimings, setPhaseTimings] = useState<{ name: string; time: number }[]>(defaultPhaseTimings); const [isLoading, setIsLoading] = useState(false); + const [user, setUser] = useState<{ + email: string; + displayName: string; + avatarUrl: string; + eloRating: number; + } | null>(null); + const [error, setError] = useState(null); const [expandedLevel, setExpandedLevel] = useState(null); const navigate = useNavigate(); @@ -216,6 +225,30 @@ const BotSelection: React.FC = () => { ? allBots.find((b) => b.name === selectedBot) : null; + // Fetch user profile when component mounts + useEffect(() => { + const fetchUserData = async () => { + const token = getAuthToken(); + if (!token) { + setError("No authentication token found"); + return; + } + try { + const response = await getProfile(token); + setUser({ + email: response.profile.email, + displayName: response.profile.displayName, + avatarUrl: response.profile.avatarUrl, + eloRating: response.profile.eloRating, + }); + } catch (err) { + setError("Failed to load user profile"); + console.error(err); + } + }; + fetchUserData(); + }, []); + // Difficulty levels with counts, sorted by difficulty const levels = [ { @@ -256,14 +289,21 @@ const BotSelection: React.FC = () => { }; const startDebate = async () => { - if (!selectedBot || !effectiveTopic) return; + if (!selectedBot || !effectiveTopic) { + setError("Please select a bot and a topic"); + return; + } const bot = allBots.find((b) => b.name === selectedBot); - if (!bot) return; + if (!bot) { + setError("Selected bot not found"); + return; + } const finalStance = stance === "random" ? (Math.random() < 0.5 ? "for" : "against") : stance; + // Build payload const debatePayload = { botName: bot.name, botLevel: bot.level, @@ -276,11 +316,22 @@ const BotSelection: React.FC = () => { try { setIsLoading(true); const data = await createDebate(debatePayload); - navigate(`/debate/${data.debateId}`, { - state: { ...data, phaseTimings, stance: finalStance }, - }); + const state = { + ...data, + phaseTimings, + stance: finalStance, + user: user || { + email: "", + firstName: "Guest", + lastName: "", + avatarUrl: "https://api.dicebear.com/9.x/big-ears/svg?seed=Guest", + eloRating: 1500, + }, + }; + navigate(`/debate/${data.debateId}`, { state }); } catch (error) { console.error("Error starting debate:", error); + setError("Failed to start debate"); } finally { setIsLoading(false); } @@ -298,6 +349,7 @@ const BotSelection: React.FC = () => {

Select a bot and set up your debate challenge.

+ {error &&

{error}

} {/* Main Content Grid */} @@ -504,7 +556,7 @@ const BotSelection: React.FC = () => {
-

Stance: {state.botStance}

+

+ Stance: {state.botStance} +

- Time: {formatTime(state.isBotTurn ? state.timer : phases[state.currentPhase]?.time || 0)} + Time:{" "} + {formatTime( + state.isBotTurn + ? state.timer + : phases[state.currentPhase]?.time || 0 + )}

{renderPhaseMessages("Bot")}
@@ -522,29 +662,54 @@ const DebateRoom: React.FC = () => { {/* User Section */}
- You + {debateData.user.displayName}
-
You
-
Debater
+
+ {debateData.user.displayName} +
+
+ Elo: {debateData.user.eloRating} +
Ready to argue!
-

Stance: {state.userStance}

+

+ Stance: {state.userStance} +

- Time: {formatTime(!state.isBotTurn ? state.timer : phases[state.currentPhase]?.time || 0)} + Time:{" "} + {formatTime( + !state.isBotTurn + ? state.timer + : phases[state.currentPhase]?.time || 0 + )}

-
{renderPhaseMessages("User")}
+
+ {renderPhaseMessages("User")} +
{!state.isDebateEnded && (
!isRecognizing && setFinalInput(e.target.value)} + value={ + isRecognizing + ? finalInput + (interimInput ? " " + interimInput : "") + : finalInput + } + onChange={(e) => + !isRecognizing && setFinalInput(e.target.value) + } readOnly={isRecognizing} disabled={state.isBotTurn || state.timer === 0} placeholder={ @@ -561,7 +726,11 @@ const DebateRoom: React.FC = () => { disabled={state.isBotTurn || state.timer === 0} className="bg-blue-500 hover:bg-blue-600 text-white rounded-md p-2" > - {isRecognizing ? : } + {isRecognizing ? ( + + ) : ( + + )} @@ -358,7 +418,6 @@ const Profile: React.FC = () => { elo: { label: "Elo", color: "hsl(var(--primary))" }, }; - // Filter Elo history based on selected filter const filterEloHistory = () => { if (!stats.eloHistory) return []; let filteredHistory = [...stats.eloHistory]; @@ -405,7 +464,6 @@ const Profile: React.FC = () => { const filteredEloHistory = filterEloHistory(); - // Calculate domain for Y-axis based on highest and lowest Elo values const eloValues = filteredEloHistory.map((entry) => entry.elo); const minElo = eloValues.length > 0 @@ -418,7 +476,6 @@ const Profile: React.FC = () => { const padding = Math.round((maxElo - minElo) * 0.1) || 50; const yDomain = [minElo - padding, maxElo + padding]; - // Custom tooltip content interface CustomTooltipProps { active?: boolean; payload?: Array<{ value: number }>; @@ -465,7 +522,6 @@ const Profile: React.FC = () => { return (
- {/* Left Column: Profile Details */}
{successMessage && (
@@ -478,13 +534,26 @@ const Profile: React.FC = () => {
)}
-
+
Avatar +
+ setIsAvatarModalOpen(false)} + onSelectAvatar={handleAvatarSelect} + currentAvatar={profile.avatarUrl} + /> {editingField === "displayName" ? (
handleSubmit(e, "displayName")} @@ -583,10 +652,8 @@ const Profile: React.FC = () => {
- {/* Right Column: Dashboard */}
- {/* Donut Chart */} {totalMatches === 0 ? ( @@ -659,7 +726,6 @@ const Profile: React.FC = () => { - {/* Elo Trend */}
@@ -807,9 +873,7 @@ const Profile: React.FC = () => {
- {/* Bottom row: Leaderboard + Recent Debates */}
- {/* Mini Leaderboard */} @@ -866,7 +930,6 @@ const Profile: React.FC = () => { - {/* Recent Debates */} diff --git a/frontend/src/components/AvatarModal.tsx b/frontend/src/components/AvatarModal.tsx new file mode 100644 index 0000000..14c9b32 --- /dev/null +++ b/frontend/src/components/AvatarModal.tsx @@ -0,0 +1,755 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Slider } from '@/components/ui/slider'; +import { X } from 'lucide-react'; + +interface AvatarModalProps { + isOpen: boolean; + onClose: () => void; + onSelectAvatar: (avatarUrl: string) => void; + currentAvatar?: string; +} + +const AvatarModal: React.FC = ({ + isOpen, + onClose, + onSelectAvatar, + currentAvatar, +}) => { + const [seed, setSeed] = useState( + currentAvatar?.split('seed=')[1]?.split('&')[0] || 'Jude' + ); + const [backgroundColor, setBackgroundColor] = useState('b6e3f4'); + const [ear, setEar] = useState('variant01'); + const [eyes, setEyes] = useState('variant01'); + const [cheek, setCheek] = useState('variant01'); + const [face, setFace] = useState('variant01'); + const [frontHair, setFrontHair] = useState('variant01'); + const [hair, setHair] = useState('long01'); + const [mouth, setMouth] = useState('variant0101'); + const [sideburn, setSideburn] = useState('variant01'); + const [skinColor, setSkinColor] = useState('89532c'); + const [rotate, setRotate] = useState(0); + const [scale, setScale] = useState(100); + const [selectedAvatar, setSelectedAvatar] = useState( + currentAvatar || `https://api.dicebear.com/9.x/big-ears/svg?seed=${seed}` + ); + const [seedJustSelected, setSeedJustSelected] = useState(false); + const [stylesModified, setStylesModified] = useState(false); + + const defaultValues = { + backgroundColor: 'b6e3f4', + ear: 'variant01', + eyes: 'variant01', + cheek: 'variant01', + face: 'variant01', + frontHair: 'variant01', + hair: 'long01', + mouth: 'variant0101', + sideburn: 'variant01', + skinColor: '89532c', + rotate: 0, + scale: 100, + }; + + const seeds = [ + 'Felix', + 'Aneka', + 'Leah', + 'Jude', + 'Sadie', + 'Nolan', + 'Luis', + 'Robert', + 'Easton', + 'Eden', + 'Jocelyn', + 'Ryan', + 'Riley', + 'Chase', + 'George', + 'Kimberly', + 'Liam', + 'Avery', + 'Maria', + 'Eliza', + 'Brooklynn', + 'Vivian', + ]; + + const backgroundColors = ['b6e3f4', 'c0aede', 'd1d4f9', 'ffd5dc', 'ffdfbf']; + const earOptions = [ + 'variant01', + 'variant02', + 'variant03', + 'variant04', + 'variant05', + 'variant06', + 'variant07', + 'variant08', + ]; + const eyesOptions = [ + 'variant01', + 'variant02', + 'variant03', + 'variant04', + 'variant05', + 'variant06', + 'variant07', + 'variant08', + 'variant09', + 'variant10', + 'variant11', + 'variant12', + 'variant13', + 'variant14', + 'variant15', + 'variant16', + 'variant17', + 'variant18', + 'variant19', + 'variant20', + 'variant21', + 'variant22', + 'variant23', + 'variant24', + 'variant25', + 'variant26', + 'variant27', + 'variant28', + 'variant29', + 'variant30', + 'variant31', + 'variant32', + ]; + const cheekOptions = [ + 'variant01', + 'variant02', + 'variant03', + 'variant04', + 'variant05', + 'variant06', + ]; + const faceOptions = [ + 'variant01', + 'variant02', + 'variant03', + 'variant04', + 'variant05', + 'variant06', + 'variant07', + 'variant08', + 'variant09', + 'variant10', + ]; + const frontHairOptions = [ + 'variant01', + 'variant02', + 'variant03', + 'variant04', + 'variant05', + 'variant06', + 'variant07', + 'variant08', + 'variant09', + 'variant10', + 'variant11', + 'variant12', + ]; + const hairOptions = [ + 'long01', + 'long02', + 'long03', + 'long04', + 'long05', + 'long06', + 'long07', + 'long08', + 'long09', + 'long10', + 'long11', + 'long12', + 'long13', + 'long14', + 'long15', + 'long16', + 'long17', + 'long18', + 'long19', + 'long20', + 'short01', + 'short02', + 'short03', + 'short04', + 'short05', + 'short06', + 'short07', + 'short08', + 'short09', + 'short10', + 'short11', + 'short12', + 'short13', + 'short14', + 'short15', + 'short16', + 'short17', + 'short18', + 'short19', + 'short20', + ]; + const mouthOptions = [ + 'variant0101', + 'variant0102', + 'variant0103', + 'variant0104', + 'variant0105', + 'variant0201', + 'variant0202', + 'variant0203', + 'variant0204', + 'variant0205', + 'variant0301', + 'variant0302', + 'variant0303', + 'variant0304', + 'variant0305', + 'variant0401', + 'variant0402', + 'variant0403', + 'variant0404', + 'variant0405', + 'variant0501', + 'variant0502', + 'variant0503', + 'variant0504', + 'variant0505', + 'variant0601', + 'variant0602', + 'variant0603', + 'variant0604', + 'variant0605', + 'variant0701', + 'variant0702', + 'variant0703', + 'variant0704', + 'variant0705', + 'variant0706', + 'variant0707', + 'variant0708', + ]; + const sideburnOptions = [ + 'variant01', + 'variant02', + 'variant03', + 'variant04', + 'variant05', + 'variant06', + 'variant07', + ]; + const skinColorOptions = ['89532c', 'a66637', 'c07f50', 'da9969', 'f8b788']; + + const generateAvatarUrl = () => { + const params = { + seed, + skinColor, + rotate, + scale, + ...(stylesModified && { + backgroundColor, + ear, + eyes, + cheek, + face, + frontHair, + hair, + mouth, + sideburn, + }), + }; + let url = `https://api.dicebear.com/9.x/big-ears/svg?seed=${params.seed}`; + if (skinColor !== defaultValues.skinColor) + url += `&skinColor=${params.skinColor}`; + if (rotate !== defaultValues.rotate) url += `&rotate=${params.rotate}`; + if (scale !== defaultValues.scale) url += `&scale=${params.scale}`; + if (stylesModified) { + url += `&backgroundColor=${params.backgroundColor}&ear=${params.ear}&eyes=${params.eyes}&cheek=${params.cheek}&face=${params.face}&frontHair=${params.frontHair}&hair=${params.hair}&mouth=${params.mouth}&sideburn=${params.sideburn}`; + } + return url; + }; + + const generateSeedOnlyUrl = (seed: string) => { + return `https://api.dicebear.com/9.x/big-ears/svg?seed=${seed}`; + }; + + // Memoized preview URLs for Jude to prevent reloading + const judePreviewUrls = useMemo( + () => ({ + ear: earOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&ear=${option}`, + })), + eyes: eyesOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&eyes=${option}`, + })), + cheek: cheekOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&cheek=${option}`, + })), + face: faceOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&face=${option}`, + })), + frontHair: frontHairOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&frontHair=${option}`, + })), + hair: hairOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&hair=${option}`, + })), + mouth: mouthOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&mouth=${option}`, + })), + sideburn: sideburnOptions.map((option) => ({ + option, + url: generateSeedOnlyUrl('Jude') + `&sideburn=${option}`, + })), + }), + [] + ); + + // Check if non-special styles are at default values + const areNonSpecialStylesDefault = () => { + return ( + backgroundColor === defaultValues.backgroundColor && + ear === defaultValues.ear && + eyes === defaultValues.eyes && + cheek === defaultValues.cheek && + face === defaultValues.face && + frontHair === defaultValues.frontHair && + hair === defaultValues.hair && + mouth === defaultValues.mouth && + sideburn === defaultValues.sideburn + ); + }; + + useEffect(() => { + if (seedJustSelected || areNonSpecialStylesDefault()) { + let url = generateSeedOnlyUrl(seed); + if (skinColor !== defaultValues.skinColor) + url += `&skinColor=${skinColor}`; + if (rotate !== defaultValues.rotate) url += `&rotate=${rotate}`; + if (scale !== defaultValues.scale) url += `&scale=${scale}`; + setSelectedAvatar(url); + } else { + setSelectedAvatar(generateAvatarUrl()); + } + }, [ + seed, + backgroundColor, + ear, + eyes, + cheek, + face, + frontHair, + hair, + mouth, + sideburn, + skinColor, + rotate, + scale, + seedJustSelected, + ]); + + const handleSeedSelect = (newSeed: string) => { + setSeed(newSeed); + setSeedJustSelected(true); + setStylesModified(false); + // Reset all styles to default when a new seed is selected + setBackgroundColor(defaultValues.backgroundColor); + setEar(defaultValues.ear); + setEyes(defaultValues.eyes); + setCheek(defaultValues.cheek); + setFace(defaultValues.face); + setFrontHair(defaultValues.frontHair); + setHair(defaultValues.hair); + setMouth(defaultValues.mouth); + setSideburn(defaultValues.sideburn); + setSkinColor(defaultValues.skinColor); + setRotate(defaultValues.rotate); + setScale(defaultValues.scale); + }; + + // Generic handleStyleSelect to handle both string and number state setters + const handleStyleSelect = ( + setter: React.Dispatch>, + value: T + ) => { + setter(value); + // Only set stylesModified to true for non-special styles + if ( + setter !== setRotate && + setter !== setScale && + setter !== setSkinColor + ) { + setStylesModified(true); + } + setSeedJustSelected(false); // Reset flag when any style is selected + }; + + const handleAvatarSelect = () => { + let newAvatarUrl = generateSeedOnlyUrl(seed); + if (skinColor !== defaultValues.skinColor) + newAvatarUrl += `&skinColor=${skinColor}`; + if (rotate !== defaultValues.rotate) newAvatarUrl += `&rotate=${rotate}`; + if (scale !== defaultValues.scale) newAvatarUrl += `&scale=${scale}`; + if (stylesModified) { + newAvatarUrl = generateAvatarUrl(); + } + setSelectedAvatar(newAvatarUrl); + onSelectAvatar(newAvatarUrl); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Fixed Preview */} +
+
+

Customize Your Avatar

+ +
+
+ Avatar Preview +
+
+ + {/* Character Selection */} +
+ +
+ {seeds.map((s) => ( + + ))} +
+
+ + {/* Customization Options */} +
+ {/* Background Color */} +
+ +
+ {backgroundColors.map((color) => ( +
+
+ + {/* Ear Style */} +
+ +
+ {judePreviewUrls.ear.map(({ option, url }) => ( + + ))} +
+
+ + {/* Eyes Style */} +
+ +
+ {judePreviewUrls.eyes.map(({ option, url }) => ( + + ))} +
+
+ + {/* Cheek Style */} +
+ +
+ {judePreviewUrls.cheek.map(({ option, url }) => ( + + ))} +
+
+ + {/* Face Style */} +
+ +
+ {judePreviewUrls.face.map(({ option, url }) => ( + + ))} +
+
+ + {/* Front Hair Style */} +
+ +
+ {judePreviewUrls.frontHair.map(({ option, url }) => ( + + ))} +
+
+ + {/* Hair Style */} +
+ +
+ {judePreviewUrls.hair.map(({ option, url }) => ( + + ))} +
+
+ + {/* Mouth Style */} +
+ +
+ {judePreviewUrls.mouth.map(({ option, url }) => ( + + ))} +
+
+ + {/* Sideburn Style */} +
+ +
+ {judePreviewUrls.sideburn.map(({ option, url }) => ( + + ))} +
+
+ + {/* Skin Color */} +
+ +
+ {skinColorOptions.map((color) => ( +
+
+ + {/* Sliders */} +
+ {/* Rotation Slider */} +
+ + + handleStyleSelect(setRotate, value[0]) + } + min={0} + max={360} + step={1} + className='mt-2' + /> +
+ + {/* Scale Slider */} +
+ + handleStyleSelect(setScale, value[0])} + min={50} + max={200} + step={1} + className='mt-2' + /> +
+
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ); +}; + +export default AvatarModal; diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx new file mode 100644 index 0000000..9398b33 --- /dev/null +++ b/frontend/src/components/ui/slider.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/frontend/src/services/profileService.ts b/frontend/src/services/profileService.ts index c8c1d08..2aa96f8 100644 --- a/frontend/src/services/profileService.ts +++ b/frontend/src/services/profileService.ts @@ -20,7 +20,8 @@ export const updateProfile = async ( bio: string, twitter?: string, instagram?: string, - linkedin?: string + linkedin?: string, + avatarUrl?: string ) => { const response = await fetch(`${baseURL}/user/updateprofile`, { method: "PUT", @@ -28,7 +29,7 @@ export const updateProfile = async ( "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ displayName, bio, twitter, instagram, linkedin }), + body: JSON.stringify({ displayName, bio, twitter, instagram, linkedin,avatarUrl }), }); if (!response.ok) { throw new Error("Failed to update profile");