diff --git a/.air.conf b/.air.conf index 6d298ee..76974d2 100644 --- a/.air.conf +++ b/.air.conf @@ -1,17 +1,14 @@ root = "." -tmp_dir = "tmp" [build] - cmd = "go build -o ./tmp/main ./src/main.go" - bin = "tmp/main" - full_bin = "tmp/main" - include_ext = ["go", "tpl", "tmpl", "html", "css", "js"] - exclude_dir = ["tmp", "vendor"] + cmd = "nix develop -c go build -o ./tmp/main ./src/..." # for nix users + include_ext = ["go", "html", "css"] + exclude_dir = ["tmp"] [log] + level = "warn" time = true -[colors] - main = "yellow" - watcher = "cyan" - build = "green" +[env] + GIN_MODE = "debug" # change to "release" for production + APP_PORT = 18765 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d0be2e5..d839785 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -qrank.db \ No newline at end of file +qrank.db +tmp \ No newline at end of file diff --git a/README.md b/README.md index 5d0b1ab..25d173b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ # QRank -The *easy* tracker for tabletop soccers with QR code integration. - +The **easy** tracker for tabletop soccers with QR code integration. Everything is still in development. Feel free to open issues and or pull requests. +This application is build with [go](https://go.dev/) and [gin](https://gin-gonic.com/). Other languages like HTML and CSS are used. + ## Getting started -To run the application go to the root directory and run `make run` in the terminal. On a nix system it is possible to run with all dependencies via `make nix`. The listening address is `https://localhost:18765`. +To run the application go to the root directory and run `make run` in the terminal. On a nix system it is possible to run with all dependencies via `make nix-run`. The default listening address is `http://localhost:18765`. + +For a development environment install [air](https://github.com/air-verse/air) and run the development server with `air` from the root directory. diff --git a/assets/styles.css b/assets/styles.css index da5af47..c61934b 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -42,12 +42,42 @@ body { } .input { - width: 100%; + width: 75%; padding: 10px; border-radius: 10px; border: 1px solid #ddd } +a, a:link, a:visited, a:hover, a:active { + color: darkblue; /* always dark blue */ + text-decoration: none; /* remove underline */ +} + +.alert { + padding: 10px 14px; + border-radius: 6px; + margin: 10px 0; + font-size: 0.9rem; +} + +.alert-error { + background: #ffe5e5; + border: 1px solid #e74c3c; + color: #c0392b; +} + +.alert-success { + background: #e6ffed; + border: 1px solid #2ecc71; + color: #27ae60; +} + +.alert-info { + background: #eaf4ff; + border: 1px solid #3498db; + color: #2980b9; +} + .label { font-size: 12px; color: #444; diff --git a/flake.nix b/flake.nix index 66b9d08..4cb09db 100644 --- a/flake.nix +++ b/flake.nix @@ -12,10 +12,9 @@ devShells = forAllSystems (pkgs: let # Pick the Go you want. pkgs.go is fine; change to pkgs.go_1_23 if you prefer a fixed version. - go = pkgs.go; + go = pkgs.go_1_24; in { default = pkgs.mkShell { - # Tools required for cgo and sqlite buildInputs = [ go pkgs.gcc @@ -23,25 +22,18 @@ pkgs.sqlite ]; - # Optional (handy) tools nativeBuildInputs = [ pkgs.git ]; - # Enable CGO so mattn/go-sqlite3 works CGO_ENABLED = 1; - - # If you plan static linking, uncomment: - # hardeningDisable = [ "fortify" ]; - shellHook = '' - echo "🔧 Nix dev shell ready (CGO_ENABLED=1)." - echo "▶ Run: go run ." + echo 'Inside nix shell.' ''; }; }); - # Convenience runner: `nix run` + # Used for nix run apps = forAllSystems (pkgs: { default = { type = "app"; @@ -53,7 +45,7 @@ }; }); - # Optional: formatter for this repo + # Nix formatter formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt); }; } diff --git a/src/auth.go b/src/auth.go index 9561dc3..2b78bce 100644 --- a/src/auth.go +++ b/src/auth.go @@ -33,8 +33,8 @@ func requireAuth() gin.HandlerFunc { } func setSessionCookie(c *gin.Context, token string) { - // Very long-lived cookie (~10 years) - maxAge := 10 * 365 * 24 * 60 * 60 + // Very long-lived cookie for one year + maxAge := 365 * 24 * 60 * 60 httpOnly := true secure := strings.HasPrefix(baseURL, "https://") sameSite := http.SameSiteLaxMode @@ -43,36 +43,23 @@ func setSessionCookie(c *gin.Context, token string) { c.Header("Set-Cookie", (&http.Cookie{Name: "session", Value: token, Path: "/", Domain: cookieDomain, MaxAge: maxAge, Secure: secure, HttpOnly: httpOnly, SameSite: sameSite}).String()) } -func findOrCreateUserByHandle(handle string) (*User, error) { +func findUserByHandle(handle string) (*User, error) { h := strings.TrimSpace(handle) if h == "" { return nil, sql.ErrNoRows } - if strings.Contains(h, "@") { // email + + // Return user by email or username + if strings.Contains(h, "@") { if u, err := userByEmail(strings.ToLower(h)); err == nil { return u, nil } - // create - u := &User{Email: strings.ToLower(h), Username: defaultUsername()} - if err := ensureUniqueUsernameAndSlug(u); err != nil { - return nil, err + } else { + // Find and return the user + if u, err := userByName(h); err == nil { + return u, nil } - if err := db.Create(u).Error; err != nil { - return nil, err - } - return u, nil } - // username - var u User - if err := db.Where("username = ?", h).First(&u).Error; err == nil { - return &u, nil - } - // If not found, create placeholder user with random email-like token - u = User{Email: mustRandToken(3) + "+placeholder@local", Username: h, Slug: slugify(h)} - if err := ensureUniqueUsernameAndSlug(&u); err != nil { /* best-effort */ - } - if err := db.Create(&u).Error; err != nil { - return nil, err - } - return &u, nil + + return nil, sql.ErrNoRows } diff --git a/src/main.go b/src/main.go index 34dc630..136ecb5 100644 --- a/src/main.go +++ b/src/main.go @@ -4,8 +4,9 @@ import ( "log" "os" + "strconv" + "github.com/gin-gonic/gin" - "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -16,46 +17,44 @@ var ( db *gorm.DB baseURL string cookieDomain string + + // Global logger for this package + lg = log.Default() ) const ( - PORT = "18765" + // Default port if APP_PORT is not set + defaultPort = 18765 ) -var logger = log.Default() - // ===================== Main ===================== func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) + // Get the listening port + port := os.Getenv("APP_PORT") + port = strconv.Itoa(defaultPort) + + // Set the base URL baseURL = os.Getenv("APP_BASE_URL") if baseURL == "" { - baseURL = "http://localhost:" + PORT + baseURL = "http://localhost:" + port } - cookieDomain = os.Getenv("APP_COOKIE_DOMAIN") - // DB connect + // Open connection to SQLite var err error - dsn := os.Getenv("DATABASE_URL") - if dsn != "" { - db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - log.Fatal("postgres connect:", err) - } - log.Println("using Postgres") - } else { - db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{}) - if err != nil { - log.Fatal("sqlite connect:", err) - } - log.Println("using SQLite qrank.db") + db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{}) + if err != nil { + lg.Fatal("sqlite connect:", err) } + lg.Println("using SQLite qrank.db") if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GamePlayer{}); err != nil { - log.Fatal("migrate:", err) + lg.Fatal("migrate:", err) } + // Create engine r := gin.Default() // Serve static files from the current directory @@ -82,12 +81,10 @@ func main() { r.GET("/me", requireAuth(), getMe) r.POST("/me", requireAuth(), postMe) - bind := os.Getenv("APP_BIND") - if bind == "" { - bind = ":" + PORT - } - log.Println("listening on", bind, "base:", baseURL) + // Start application with port + bind := ":" + port + lg.Println("listening on", bind, "base:", baseURL) if err := r.Run(bind); err != nil { - log.Fatal(err) + lg.Fatal(err) } } diff --git a/src/models.go b/src/models.go index d97053f..1cf0c75 100644 --- a/src/models.go +++ b/src/models.go @@ -4,6 +4,7 @@ import ( "time" ) +// One knows that the user is active when there exists a session for the user type User struct { ID uint `gorm:"primaryKey"` CreatedAt time.Time @@ -14,13 +15,13 @@ type User struct { Slug string `gorm:"uniqueIndex;size:80"` } +// Currently no expiry type Session struct { ID uint `gorm:"primaryKey"` CreatedAt time.Time - // No expiry by default; can add later - Token string `gorm:"uniqueIndex;size:128"` - UserID uint `gorm:"index"` - User User + Token string `gorm:"uniqueIndex;size:128"` + UserID uint `gorm:"index"` + User User } type LoginToken struct { @@ -46,6 +47,7 @@ type Game struct { Table *Table ScoreA int ScoreB int + WinnerIsA bool } type GamePlayer struct { @@ -55,3 +57,10 @@ type GamePlayer struct { Side string `gorm:"size:1;index"` // "A" or "B" User User } + +// Also house the common models that are not in the database here +type stats struct { + Games, + Wins, + Losses int +} diff --git a/src/handlers.go b/src/routes.go similarity index 66% rename from src/handlers.go rename to src/routes.go index ec303ca..8f00e4c 100644 --- a/src/handlers.go +++ b/src/routes.go @@ -2,7 +2,9 @@ package main import ( "errors" + "fmt" "log" + "net/http" "strings" "time" @@ -10,125 +12,86 @@ import ( "gorm.io/gorm" ) -func getIndex(c *gin.Context) { - if getSessionUser(c) != nil { - c.Redirect(302, "/enter") - return - } - getLogin(c) -} - -func getLogin(c *gin.Context) { - tm.Render(c, "login", gin.H{}) -} - -func postLogin(c *gin.Context) { - email := strings.ToLower(strings.TrimSpace(c.PostForm("email"))) - if email == "" { - c.Redirect(302, "/login") - return - } - - // ensure user exists - var u User - tx := db.Where("email = ?", email).First(&u) - if errors.Is(tx.Error, gorm.ErrRecordNotFound) { - u = User{Email: email, Username: defaultUsername()} - if err := ensureUniqueUsernameAndSlug(&u); err != nil { - log.Println(err) - } - if err := db.Create(&u).Error; err != nil { - log.Println("create user:", err) - } - } - - // create token valid 30 days - token := mustRandToken(24) - lt := LoginToken{Token: token, Email: email, ExpiresAt: now().Add(30 * 24 * time.Hour)} - if err := db.Create(<).Error; err != nil { - log.Println("create token:", err) - } - - link := strings.TrimRight(baseURL, "/") + "/magic?token=" + token - log.Printf("[MAGIC LINK] %s for %s (valid until %s)\n", link, email, lt.ExpiresAt.Format(time.RFC3339)) - - tm.Render(c, "sent", gin.H{"Email": email}) -} - -func getMagic(c *gin.Context) { - token := c.Query("token") - if token == "" { - c.Redirect(302, "/login") - return - } - var lt LoginToken - if err := db.Where("token = ? AND expires_at > ?", token, now()).First(<).Error; err != nil { - c.String(400, "Invalid or expired token") - return - } - // find or create user by email again (in case) - u, err := userByEmail(lt.Email) - if err != nil { - c.String(500, "user lookup error") - return - } - - // create session - sessTok := mustRandToken(24) - if err := db.Create(&Session{Token: sessTok, UserID: u.ID}).Error; err != nil { - c.String(500, "session error") - return - } - setSessionCookie(c, sessTok) - - c.Redirect(302, "/enter") -} - func getEnter(c *gin.Context) { - var table *Table - if slug := c.Param("tslug"); slug != "" { - var t Table - if err := db.Where("slug = ?", slug).First(&t).Error; err == nil { - table = &t - } - } - post := "/enter" - if table != nil { - post = "/t/" + table.Slug + "/enter" - } - tm.Render(c, "enter", gin.H{"PostAction": post, "Table": table}) + // Simple render for now + tm.Render(c, "enter", gin.H{}) } func postEnter(c *gin.Context) { current := getSessionUser(c) if current == nil { - c.Redirect(302, "/login") + c.Redirect(http.StatusFound, "/login") return } - a1h := c.PostForm("a1") - a2h := c.PostForm("a2") - b1h := c.PostForm("b1") - b2h := c.PostForm("b2") + // Parse form + a1h := strings.TrimSpace(c.PostForm("a1")) + a2h := strings.TrimSpace(c.PostForm("a2")) + b1h := strings.TrimSpace(c.PostForm("b1")) + b2h := strings.TrimSpace(c.PostForm("b2")) scoreA := atoiSafe(c.PostForm("scoreA")) scoreB := atoiSafe(c.PostForm("scoreB")) - // Resolve players (default A1 to current if empty) - if strings.TrimSpace(a1h) == "" { - a1h = current.Username - } - a1, _ := findOrCreateUserByHandle(a1h) - var a2, b1, b2 *User - if a2h != "" { - a2, _ = findOrCreateUserByHandle(a2h) - } - if b1h != "" { - b1, _ = findOrCreateUserByHandle(b1h) - } - if b2h != "" { - b2, _ = findOrCreateUserByHandle(b2h) + // Require a winner + if scoreA == scoreB { + tm.Render(c, "enter", gin.H{"Error": "Score A score must be different from score B", + "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + return } + // Resolve players + resolveUser := func(handle string) (*User, error) { + if handle == "" { + return nil, nil + } + u, err := findUserByHandle(handle) + if err != nil { + tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q not found", handle), + "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + return nil, err + } + return u, nil + } + + var a1, a2, b1, b2 *User + + // Always ensure A1 exists, fallback to current if empty + var err error + if a1, err = resolveUser(a1h); err != nil { + return + } + if a2, err = resolveUser(a2h); err != nil { + return + } + if b1, err = resolveUser(b1h); err != nil { + return + } + if b2, err = resolveUser(b2h); err != nil { + return + } + + // Check for at least one player on each side + if b1 == nil && b2 == nil || a1 == nil && a2 == nil { + tm.Render(c, "enter", gin.H{"Error": "At least one player required on each side", + "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + return + } + + // Check for duplicate users + seen := map[uint]bool{} + users := []*User{a1, a2, b1, b2} + for i, u := range users { + if u != nil { + if seen[u.ID] { + tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q specified multiple times", users[i].Username), + "a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB}) + return + } + seen[u.ID] = true + } + } + + // Look up table if provided var tableID *uint if slug := c.Param("tslug"); slug != "" { var t Table @@ -137,42 +100,49 @@ func postEnter(c *gin.Context) { } } - g := Game{ScoreA: scoreA, ScoreB: scoreB, TableID: tableID} + // Create game + g := Game{ + ScoreA: scoreA, + ScoreB: scoreB, + TableID: tableID, + WinnerIsA: scoreA > scoreB, + } if err := db.Create(&g).Error; err != nil { - c.String(500, "game create error") + c.String(http.StatusInternalServerError, "game create error") return } + // Collect players by side players := []struct { - U *User - S string - }{{a1, "A"}} - if a2 != nil { - players = append(players, struct { - U *User - S string - }{a2, "A"}) - } - if b1 != nil { - players = append(players, struct { - U *User - S string - }{b1, "B"}) - } - if b2 != nil { - players = append(players, struct { - U *User - S string - }{b2, "B"}) + u *User + side string + }{ + {a1, "A"}, + {a2, "A"}, + {b1, "B"}, + {b2, "B"}, } + for _, p := range players { - if p.U != nil { - db.Create(&GamePlayer{GameID: g.ID, UserID: p.U.ID, Side: p.S}) + if p.u != nil { + if err := db.Create(&GamePlayer{GameID: g.ID, UserID: p.u.ID, Side: p.side}).Error; err != nil { + c.String(http.StatusInternalServerError, "failed to assign player") + return + } } } - c.Redirect(302, "/history") + c.Redirect(http.StatusFound, "/history") } + +func getIndex(c *gin.Context) { + if u := getSessionUser(c); u != nil { + c.Redirect(302, "/enter") + return + } + getLogin(c) +} + func getHistory(c *gin.Context) { // Load recent games with players var games []Game @@ -274,53 +244,21 @@ func getUserView(c *gin.Context) { return } - // Compute stats - type Stats struct{ Games, Wins, Losses int } - st := Stats{} - var gps []GamePlayer - db.Where("user_id = ?", u.ID).Find(&gps) - gameMap := map[uint]string{} - for _, gp := range gps { - gameMap[gp.GameID] = gp.Side - } - - if len(gameMap) > 0 { - var games []Game - ids := make([]uint, 0, len(gameMap)) - for gid := range gameMap { - ids = append(ids, gid) - } - db.Find(&games, ids) - for _, g := range games { - st.Games++ - if g.ScoreA == g.ScoreB { - continue - } - if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" { - st.Wins++ - } else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" { - st.Wins++ - } else { - st.Losses++ - } - } - } - own := false if cu := getSessionUser(c); cu != nil && cu.ID == u.ID { own = true } - tm.Render(c, "user", gin.H{"Viewed": u, "Stats": st, "Own": own}) + tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": own}) } func getMe(c *gin.Context) { - cu := getSessionUser(c) - if cu == nil { + u := getSessionUser(c) + if u == nil { c.Redirect(302, "/login") return } - tm.Render(c, "user", gin.H{"Viewed": cu, "Stats": struct{ Games, Wins, Losses int }{0, 0, 0}, "Own": true}) + tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": true}) } func postMe(c *gin.Context) { @@ -330,10 +268,12 @@ func postMe(c *gin.Context) { return } newU := strings.TrimSpace(c.PostForm("username")) - if newU == "" { + if newU == "" || newU == cu.Username { c.Redirect(302, "/me") return } + + // Update username and slug cu.Username = newU cu.Slug = slugify(newU) if err := ensureUniqueUsernameAndSlug(cu); err != nil { @@ -342,5 +282,71 @@ func postMe(c *gin.Context) { if err := db.Save(cu).Error; err != nil { log.Println("save user:", err) } - c.Redirect(302, "/u/"+cu.Slug) + c.Redirect(302, "/me") +} + +func getLogin(c *gin.Context) { + tm.Render(c, "login", gin.H{}) +} + +func postLogin(c *gin.Context) { + email := strings.ToLower(strings.TrimSpace(c.PostForm("email"))) + if email == "" { + c.Redirect(302, "/login") + return + } + + // ensure user exists + var u User + tx := db.Where("email = ?", email).First(&u) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + u = User{Email: email, Username: defaultUsername()} + if err := ensureUniqueUsernameAndSlug(&u); err != nil { + log.Println(err) + } + if err := db.Create(&u).Error; err != nil { + log.Println("create user:", err) + } + } + + // create token valid 30 days + token := mustRandToken(24) + lt := LoginToken{Token: token, Email: email, ExpiresAt: now().Add(30 * 24 * time.Hour)} + if err := db.Create(<).Error; err != nil { + log.Println("create token:", err) + } + + link := strings.TrimRight(baseURL, "/") + "/magic?token=" + token + log.Printf("[MAGIC LINK] %s for %s (valid until %s)\n", link, email, lt.ExpiresAt.Format(time.RFC3339)) + + tm.Render(c, "sent", gin.H{"Email": email}) +} + +func getMagic(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.Redirect(302, "/login") + return + } + var lt LoginToken + if err := db.Where("token = ? AND expires_at > ?", token, now()).First(<).Error; err != nil { + c.String(400, "Invalid or expired token") + return + } + // find or create user by email again (in case) + u, err := userByEmail(lt.Email) + if err != nil { + c.String(500, "user lookup error") + return + } + + // create session + sessTok := mustRandToken(24) + if err := db.Create(&Session{Token: sessTok, UserID: u.ID}).Error; err != nil { + c.String(500, "session error") + return + } + setSessionCookie(c, sessTok) + + c.Redirect(302, "/enter") } diff --git a/src/templates.go b/src/templates.go index 5d1fc95..8dc607d 100644 --- a/src/templates.go +++ b/src/templates.go @@ -30,14 +30,20 @@ func NewTemplateManager(dir string, base string, ext string) *TemplateManager { tm := &TemplateManager{ templates: make(map[string]*template.Template), funcs: template.FuncMap{ + // Attach functions to the templates here "fmtTime": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") // Go’s reference time }, + "cmpTwo": func(a, b int) bool { + return a == b + }, }, base: base, dir: dir, ext: ext, } + + // Populate template manager tm.LoadTemplates() return tm @@ -58,7 +64,7 @@ func (tm *TemplateManager) LoadTemplates() { } name := stripAfterDot(filepath.Base(file)) - logger.Println(name) + lg.Println(name) // Parse base + view template together tpl, err := template.New(name). diff --git a/src/utils.go b/src/utils.go index d8ad17c..7e4f6ee 100644 --- a/src/utils.go +++ b/src/utils.go @@ -16,12 +16,15 @@ func mustRandToken(n int) string { return hex.EncodeToString(b) } -func now() time.Time { return time.Now().UTC() } +func now() time.Time { + return time.Now().UTC() +} + +var adjectives = []string{"swift", "brave", "mighty", "cheeky", "sneaky", "zippy", "bouncy", "crispy", "fuzzy", "spicy", "snappy", "jazzy", "spry", "bold", "witty"} +var animals = []string{"otter", "panda", "falcon", "lynx", "badger", "tiger", "koala", "yak", "gecko", "eagle", "mamba", "fox", "yak", "whale", "rhino"} func defaultUsername() string { - // io-game style: adjective-animal-xxxx - adjectives := []string{"swift", "brave", "mighty", "cheeky", "sneaky", "zippy", "bouncy", "crispy", "fuzzy", "spicy", "snappy", "jazzy", "spry", "bold", "witty"} - animals := []string{"otter", "panda", "falcon", "lynx", "badger", "tiger", "koala", "yak", "gecko", "eagle", "mamba", "fox", "yak", "whale", "rhino"} + // Inspired from IO games: adjective-animal-xx a := adjectives[int(time.Now().UnixNano())%len(adjectives)] an := animals[int(time.Now().UnixNano()/17)%len(animals)] return a + "-" + an + "-" + strings.ToLower(mustRandToken(2)) @@ -72,8 +75,18 @@ func ensureUniqueUsernameAndSlug(u *User) error { } func userByEmail(email string) (*User, error) { + // Email must be lowercased and trimmed var u User - tx := db.Where("email = ?", strings.ToLower(email)).First(&u) + tx := db.Where("email = ?", email).First(&u) + if tx.Error != nil { + return nil, tx.Error + } + return &u, nil +} + +func userByName(name string) (*User, error) { + var u User + tx := db.Where("username = ?", name).First(&u) if tx.Error != nil { return nil, tx.Error } @@ -121,3 +134,36 @@ func stripAfterDot(s string) string { } return s } + +func getStatsFromUser(u *User) stats { + var gps []GamePlayer + var games, wins, losses int + db.Where("user_id = ?", u.ID).Find(&gps) + gameMap := map[uint]string{} + for _, gp := range gps { + gameMap[gp.GameID] = gp.Side + } + + if len(gameMap) > 0 { + var gamesL []Game + ids := make([]uint, 0, len(gameMap)) + for gid := range gameMap { + ids = append(ids, gid) + } + db.Find(&gamesL, ids) + for _, g := range gamesL { + games++ + if g.ScoreA == g.ScoreB { + continue + } + if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" { + wins++ + } else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" { + wins++ + } else { + losses++ + } + } + } + return stats{Games: games, Wins: wins, Losses: losses} +} diff --git a/templates/base.html b/templates/base.html index 26e764f..74482c0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,12 +5,14 @@
-Table: {{.Table.Name}}
{{end}} -