diff --git a/cmd/server/main.go b/cmd/server/main.go index 5c628e6..e9f81ed 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,7 +17,9 @@ import ( "github.com/grovecj/warzone-stats-tracker/internal/config" "github.com/grovecj/warzone-stats-tracker/internal/database" "github.com/grovecj/warzone-stats-tracker/internal/handler" + "github.com/grovecj/warzone-stats-tracker/internal/repository" "github.com/grovecj/warzone-stats-tracker/internal/router" + "github.com/grovecj/warzone-stats-tracker/internal/service" "github.com/grovecj/warzone-stats-tracker/web" ) @@ -62,8 +64,18 @@ func main() { } } + // Repositories + playerRepo := repository.NewPlayerRepo(pool) + matchRepo := repository.NewMatchRepo(pool) + + // Services + playerService := service.NewPlayerService(cachedAPI, playerRepo) + matchService := service.NewMatchService(cachedAPI, matchRepo, playerRepo) + // Handlers adminHandler := handler.NewAdminHandler(cachedAPI) + playerHandler := handler.NewPlayerHandler(playerService) + matchHandler := handler.NewMatchHandler(matchService) // Router rawOrigins := strings.Split(cfg.CORSAllowedOrigins, ",") @@ -74,8 +86,10 @@ func main() { } } mux := router.New(origins, staticFS, router.Deps{ - AdminHandler: adminHandler, - AdminAPIKey: cfg.AdminAPIKey, + AdminHandler: adminHandler, + PlayerHandler: playerHandler, + MatchHandler: matchHandler, + AdminAPIKey: cfg.AdminAPIKey, }) srv := &http.Server{ diff --git a/internal/cache/cache.go b/internal/cache/cache.go index db7ec8e..758bd20 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -60,15 +60,15 @@ func New(inner codclient.CodClient, cfg Config) *CachedClient { return c } -func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*codclient.PlayerStats, error) { - key := fmt.Sprintf("stats:%s:%s:%s", platform, gamertag, mode) +func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*codclient.PlayerStats, error) { + key := fmt.Sprintf("stats:%s:%s:%s:%s", platform, gamertag, title, mode) if val, hit := c.get(key); hit { slog.Debug("cache hit", "key", key) return val.(*codclient.PlayerStats), nil } - stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, mode) + stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, title, mode) if err != nil { // Only serve stale data for transient errors (API down, rate limited) if isTransientError(err) { @@ -84,15 +84,15 @@ func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, m return stats, nil } -func (c *CachedClient) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]codclient.Match, error) { - key := fmt.Sprintf("matches:%s:%s", platform, gamertag) +func (c *CachedClient) GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]codclient.Match, error) { + key := fmt.Sprintf("matches:%s:%s:%s:%s", platform, gamertag, title, mode) if val, hit := c.get(key); hit { slog.Debug("cache hit", "key", key) return val.([]codclient.Match), nil } - matches, err := c.inner.GetRecentMatches(ctx, platform, gamertag) + matches, err := c.inner.GetRecentMatches(ctx, platform, gamertag, title, mode) if err != nil { if isTransientError(err) { if val, ok := c.getStale(key); ok { diff --git a/internal/codclient/client.go b/internal/codclient/client.go index 46c6121..6d7a273 100644 --- a/internal/codclient/client.go +++ b/internal/codclient/client.go @@ -7,20 +7,17 @@ import ( "log/slog" "net/http" "net/url" + "strings" "sync" "time" "resty.dev/v3" ) -const ( - defaultTitle = "mw" // Modern Warfare / Warzone title code -) - // CodClient defines the interface for interacting with the Call of Duty API. type CodClient interface { - GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error) - GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error) + GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*PlayerStats, error) + GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]Match, error) UpdateToken(newToken string) } @@ -41,6 +38,7 @@ func New(baseURL, ssoToken string) CodClient { c.SetRetryMaxWaitTime(5 * time.Second) c.SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") c.SetHeader("Accept", "application/json") + c.SetRedirectPolicy(resty.NoRedirectPolicy()) return &client{http: c, baseURL: baseURL, token: ssoToken} } @@ -51,25 +49,20 @@ func (c *client) authCookie() *http.Cookie { return &http.Cookie{Name: "ACT_SSO_COOKIE", Value: c.token} } -func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error) { +func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*PlayerStats, error) { + if title == "" { + title = "mw" + } if mode == "" { mode = "wz" } encodedTag := url.PathEscape(gamertag) endpoint := fmt.Sprintf("/stats/cod/v1/title/%s/platform/%s/gamer/%s/profile/type/%s", - defaultTitle, platform, encodedTag, mode) + title, platform, encodedTag, mode) - resp, err := c.http.R(). - SetContext(ctx). - SetCookie(c.authCookie()). - Get(endpoint) + resp, err := c.doRequest(ctx, endpoint) if err != nil { - slog.Error("cod api request failed", "endpoint", endpoint, "error", err) - return nil, ErrAPIUnavailable - } - - if err := c.checkResponse(resp); err != nil { return nil, err } @@ -82,21 +75,20 @@ func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, mode st return stats, nil } -func (c *client) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error) { +func (c *client) GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]Match, error) { + if title == "" { + title = "mw" + } + if mode == "" { + mode = "wz" + } + encodedTag := url.PathEscape(gamertag) - endpoint := fmt.Sprintf("/crm/cod/v2/title/%s/platform/%s/gamer/%s/matches/wz/start/0/end/0/details", - defaultTitle, platform, encodedTag) + endpoint := fmt.Sprintf("/crm/cod/v2/title/%s/platform/%s/gamer/%s/matches/%s/start/0/end/0/details", + title, platform, encodedTag, mode) - resp, err := c.http.R(). - SetContext(ctx). - SetCookie(c.authCookie()). - Get(endpoint) + resp, err := c.doRequest(ctx, endpoint) if err != nil { - slog.Error("cod api request failed", "endpoint", endpoint, "error", err) - return nil, ErrAPIUnavailable - } - - if err := c.checkResponse(resp); err != nil { return nil, err } @@ -141,9 +133,52 @@ func (c *client) UpdateToken(newToken string) { slog.Info("cod api sso token updated") } +// doRequest performs a GET request, handling redirect errors as expired tokens. +func (c *client) doRequest(ctx context.Context, endpoint string) (*resty.Response, error) { + resp, err := c.http.R(). + SetContext(ctx). + SetCookie(c.authCookie()). + Get(endpoint) + if err != nil { + // resty returns an error on redirect when NoRedirectPolicy is set, + // but the response is still populated + if resp != nil && resp.StatusCode() >= 300 && resp.StatusCode() < 400 { + slog.Warn("cod api redirected, token likely expired", + "status", resp.StatusCode(), + "location", resp.Header().Get("Location")) + return nil, ErrTokenExpired + } + slog.Error("cod api request failed", "endpoint", endpoint, "error", err) + return nil, ErrAPIUnavailable + } + + if err := c.checkResponse(resp); err != nil { + return nil, err + } + return resp, nil +} + func (c *client) checkResponse(resp *resty.Response) error { switch resp.StatusCode() { case http.StatusOK: + body := resp.String() + // CoD API sometimes returns 200 with HTML (login page) instead of JSON + if len(body) > 0 && body[0] == '<' { + slog.Error("cod api returned html instead of json", + "content_type", resp.Header().Get("Content-Type"), + "body_prefix", body[:min(200, len(body))]) + return ErrTokenExpired + } + // CoD API returns 200 with {"status":"error"} for business-logic errors + var envelope struct { + Status string `json:"status"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } + if json.Unmarshal([]byte(body), &envelope) == nil && envelope.Status == "error" { + return c.mapAPIError(envelope.Data.Message) + } return nil case http.StatusUnauthorized: return ErrTokenExpired @@ -153,11 +188,37 @@ func (c *client) checkResponse(resp *resty.Response) error { return ErrPlayerNotFound case http.StatusTooManyRequests: return ErrRateLimited + case http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect: + // CoD API redirects to login/store page when token is expired + slog.Warn("cod api redirected, token likely expired", + "status", resp.StatusCode(), + "location", resp.Header().Get("Location")) + return ErrTokenExpired default: if resp.StatusCode() >= 500 { return ErrAPIUnavailable } - return fmt.Errorf("unexpected status %d: %s", resp.StatusCode(), resp.String()) + body := resp.String() + slog.Error("cod api unexpected status", "status", resp.StatusCode(), + "body_prefix", body[:min(200, len(body))]) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode(), body) + } +} + +// mapAPIError converts CoD API error messages into sentinel errors. +func (c *client) mapAPIError(msg string) error { + slog.Warn("cod api returned error", "message", msg) + switch { + case strings.Contains(msg, "not authenticated"): + return ErrTokenExpired + case strings.Contains(msg, "not allowed"): + return ErrPlayerNotFound + case strings.Contains(msg, "user not found"): + return ErrPlayerNotFound + case strings.Contains(msg, "rate limit"): + return ErrRateLimited + default: + return fmt.Errorf("cod api error: %s", msg) } } @@ -165,8 +226,8 @@ func (c *client) mapProfileToStats(resp profileResponse, platform, gamertag stri stats := &PlayerStats{ Platform: platform, Gamertag: gamertag, - Level: resp.Data.Level, - Prestige: resp.Data.Prestige, + Level: int(resp.Data.Level), + Prestige: int(resp.Data.Prestige), } if props, ok := resp.Data.Lifetime.All["properties"]; ok { @@ -187,5 +248,72 @@ func (c *client) mapProfileToStats(resp profileResponse, platform, gamertag stri stats.DamageDone = int(props.DamageDone) } + // Parse per-mode breakdown from resp.Data.Lifetime.Mode + stats.ModeBreakdown = c.parseModeBreakdown(resp.Data.Lifetime.Mode) + return stats } + +// parseModeBreakdown extracts per-mode stats from the API's Mode map. +func (c *client) parseModeBreakdown(modeData map[string]any) map[string]ModeStats { + if len(modeData) == 0 { + return nil + } + + breakdown := make(map[string]ModeStats, len(modeData)) + for modeName, modeVal := range modeData { + modeMap, ok := modeVal.(map[string]any) + if !ok { + continue + } + propsVal, ok := modeMap["properties"] + if !ok { + continue + } + props, ok := propsVal.(map[string]any) + if !ok { + continue + } + + breakdown[modeName] = ModeStats{ + Kills: toInt(props["kills"]), + Deaths: toInt(props["deaths"]), + KDRatio: toFloat(props["kdRatio"]), + Wins: toInt(props["wins"]), + Losses: toInt(props["losses"]), + MatchesPlayed: toInt(props["matchesPlayed"]), + ScorePerMin: toFloat(props["scorePerMinute"]), + TimePlayed: toInt(props["timePlayed"]), + TopFive: toInt(props["topFive"]), + TopTen: toInt(props["topTen"]), + TopTwentyFive: toInt(props["topTwentyFive"]), + } + } + + if len(breakdown) == 0 { + return nil + } + return breakdown +} + +func toFloat(v any) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + default: + return 0 + } +} + +func toInt(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + default: + return 0 + } +} diff --git a/internal/codclient/types.go b/internal/codclient/types.go index 7e9eb48..93ea14f 100644 --- a/internal/codclient/types.go +++ b/internal/codclient/types.go @@ -21,8 +21,24 @@ type PlayerStats struct { TopFive int `json:"topFive"` TopTen int `json:"topTen"` TopTwentyFive int `json:"topTwentyFive"` - Assists int `json:"assists"` - DamageDone int `json:"damageDone"` + Assists int `json:"assists"` + DamageDone int `json:"damageDone"` + ModeBreakdown map[string]ModeStats `json:"modeBreakdown,omitempty"` +} + +// ModeStats represents per-mode statistics from the CoD API. +type ModeStats struct { + Kills int `json:"kills"` + Deaths int `json:"deaths"` + KDRatio float64 `json:"kdRatio"` + Wins int `json:"wins"` + Losses int `json:"losses"` + MatchesPlayed int `json:"matchesPlayed"` + ScorePerMin float64 `json:"scorePerMin"` + TimePlayed int `json:"timePlayed"` + TopFive int `json:"topFive"` + TopTen int `json:"topTen"` + TopTwentyFive int `json:"topTwentyFive"` } // Match represents a single match from the CoD API. @@ -58,8 +74,8 @@ type profileResponse struct { All map[string]statsBlock `json:"all"` Mode map[string]any `json:"mode"` } `json:"lifetime"` - Level int `json:"level"` - Prestige int `json:"prestige"` + Level float64 `json:"level"` + Prestige float64 `json:"prestige"` } `json:"data"` } diff --git a/internal/handler/errors.go b/internal/handler/errors.go new file mode 100644 index 0000000..f644c5b --- /dev/null +++ b/internal/handler/errors.go @@ -0,0 +1,56 @@ +package handler + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/grovecj/warzone-stats-tracker/internal/codclient" +) + +type apiError struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// writeAPIError maps codclient sentinel errors to HTTP status codes and writes a JSON response. +func writeAPIError(w http.ResponseWriter, err error) { + var ( + status int + code string + msg string + ) + + switch { + case errors.Is(err, codclient.ErrPlayerNotFound): + status = http.StatusNotFound + code = "player_not_found" + msg = "Player not found" + case errors.Is(err, codclient.ErrPrivateProfile): + status = http.StatusForbidden + code = "private_profile" + msg = "Player profile is set to private" + case errors.Is(err, codclient.ErrTokenExpired): + status = http.StatusServiceUnavailable + code = "service_unavailable" + msg = "CoD API authentication expired" + case errors.Is(err, codclient.ErrAPIUnavailable): + status = http.StatusServiceUnavailable + code = "service_unavailable" + msg = "CoD API is currently unavailable" + case errors.Is(err, codclient.ErrRateLimited): + status = http.StatusTooManyRequests + code = "rate_limited" + msg = "Too many requests to CoD API" + default: + slog.Error("unhandled error in API handler", "error", err) + status = http.StatusInternalServerError + code = "internal_error" + msg = "An unexpected error occurred" + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(apiError{Error: code, Message: msg}) +} diff --git a/internal/handler/match.go b/internal/handler/match.go index d0dc593..a942e11 100644 --- a/internal/handler/match.go +++ b/internal/handler/match.go @@ -1,4 +1,52 @@ package handler -// Match history HTTP handlers will be implemented in issue #12. -// Route stubs are registered in router/router.go using handler.NotImplemented. +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/grovecj/warzone-stats-tracker/internal/service" +) + +// MatchHandler holds dependencies for match endpoints. +type MatchHandler struct { + matchService *service.MatchService +} + +// NewMatchHandler creates a new MatchHandler. +func NewMatchHandler(matchService *service.MatchService) *MatchHandler { + return &MatchHandler{matchService: matchService} +} + +// GetMatches handles GET /api/v1/players/{platform}/{gamertag}/matches?limit=&offset= +func (h *MatchHandler) GetMatches(w http.ResponseWriter, r *http.Request) { + platform := chi.URLParam(r, "platform") + gamertag := chi.URLParam(r, "gamertag") + + limit := 20 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + limit = parsed + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + offset = parsed + } + } + + title := r.URL.Query().Get("title") + mode := r.URL.Query().Get("mode") + + result, err := h.matchService.GetRecentMatches(r.Context(), platform, gamertag, title, mode, limit, offset) + if err != nil { + writeAPIError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} diff --git a/internal/handler/player.go b/internal/handler/player.go index 34bfad5..7f58a02 100644 --- a/internal/handler/player.go +++ b/internal/handler/player.go @@ -1,4 +1,69 @@ package handler -// Player HTTP handlers will be implemented in issue #9. -// Route stubs are registered in router/router.go using handler.NotImplemented. +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/grovecj/warzone-stats-tracker/internal/service" +) + +// PlayerHandler holds dependencies for player endpoints. +type PlayerHandler struct { + playerService *service.PlayerService +} + +// NewPlayerHandler creates a new PlayerHandler. +func NewPlayerHandler(playerService *service.PlayerService) *PlayerHandler { + return &PlayerHandler{playerService: playerService} +} + +// SearchPlayer handles GET /api/v1/players/search?gamertag=&platform= +func (h *PlayerHandler) SearchPlayer(w http.ResponseWriter, r *http.Request) { + gamertag := r.URL.Query().Get("gamertag") + platform := r.URL.Query().Get("platform") + + if gamertag == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(apiError{ + Error: "invalid_request", + Message: "gamertag query parameter is required", + }) + return + } + + if platform == "" { + platform = "uno" + } + + title := r.URL.Query().Get("title") + mode := r.URL.Query().Get("mode") + + result, err := h.playerService.SearchPlayer(r.Context(), platform, gamertag, title, mode) + if err != nil { + writeAPIError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// GetStats handles GET /api/v1/players/{platform}/{gamertag}/stats?mode= +func (h *PlayerHandler) GetStats(w http.ResponseWriter, r *http.Request) { + platform := chi.URLParam(r, "platform") + gamertag := chi.URLParam(r, "gamertag") + title := r.URL.Query().Get("title") + mode := r.URL.Query().Get("mode") + + stats, err := h.playerService.GetPlayerStats(r.Context(), platform, gamertag, title, mode) + if err != nil { + writeAPIError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) +} diff --git a/internal/model/player.go b/internal/model/player.go index 0b92a45..25920cc 100644 --- a/internal/model/player.go +++ b/internal/model/player.go @@ -6,7 +6,7 @@ type Player struct { ID string `json:"id"` Platform string `json:"platform"` Gamertag string `json:"gamertag"` - ActivisionID string `json:"activisionId,omitempty"` + ActivisionID *string `json:"activisionId,omitempty"` LastFetchedAt *time.Time `json:"lastFetchedAt,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/internal/repository/match_repo.go b/internal/repository/match_repo.go index 4b43ca2..c9ea7a7 100644 --- a/internal/repository/match_repo.go +++ b/internal/repository/match_repo.go @@ -27,7 +27,7 @@ func (r *MatchRepo) UpsertBatch(ctx context.Context, playerID string, matches [] INSERT INTO matches (match_id, player_id, mode, map_name, placement, kills, deaths, damage_dealt, damage_taken, gulag_result, match_time, raw_data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (match_id) DO NOTHING + ON CONFLICT (match_id, player_id) DO NOTHING `, m.MatchID, playerID, m.Mode, m.MapName, m.Placement, m.Kills, m.Deaths, m.DamageDealt, m.DamageTaken, m.GulagResult, m.MatchTime, rawJSON) @@ -71,3 +71,9 @@ func (r *MatchRepo) GetByPlayerID(ctx context.Context, playerID string, limit, o } return matches, nil } + +func (r *MatchRepo) CountByPlayerID(ctx context.Context, playerID string) (int, error) { + var count int + err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM matches WHERE player_id = $1`, playerID).Scan(&count) + return count, err +} diff --git a/internal/router/router.go b/internal/router/router.go index ab7cf72..d182a7e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -15,8 +15,10 @@ import ( // Deps holds dependencies injected into the router. type Deps struct { - AdminHandler *handler.AdminHandler - AdminAPIKey string + AdminHandler *handler.AdminHandler + PlayerHandler *handler.PlayerHandler + MatchHandler *handler.MatchHandler + AdminAPIKey string } func New(allowedOrigins []string, staticFS fs.FS, deps Deps) http.Handler { @@ -39,11 +41,20 @@ func New(allowedOrigins []string, staticFS fs.FS, deps Deps) http.Handler { r.Route("/api/v1", func(r chi.Router) { r.Get("/health", handler.Health) - // Player routes (issue #9) + // Player routes r.Route("/players", func(r chi.Router) { - r.Get("/search", handler.NotImplemented) - r.Get("/{platform}/{gamertag}/stats", handler.NotImplemented) - r.Get("/{platform}/{gamertag}/matches", handler.NotImplemented) + if deps.PlayerHandler != nil { + r.Get("/search", deps.PlayerHandler.SearchPlayer) + r.Get("/{platform}/{gamertag}/stats", deps.PlayerHandler.GetStats) + } else { + r.Get("/search", handler.NotImplemented) + r.Get("/{platform}/{gamertag}/stats", handler.NotImplemented) + } + if deps.MatchHandler != nil { + r.Get("/{platform}/{gamertag}/matches", deps.MatchHandler.GetMatches) + } else { + r.Get("/{platform}/{gamertag}/matches", handler.NotImplemented) + } }) // Comparison routes (issue #14) diff --git a/internal/service/match.go b/internal/service/match.go index d1c8ec0..35d4e87 100644 --- a/internal/service/match.go +++ b/internal/service/match.go @@ -1,3 +1,101 @@ package service -// Match business logic will be implemented in issue #12. +import ( + "context" + "log/slog" + + "github.com/grovecj/warzone-stats-tracker/internal/codclient" + "github.com/grovecj/warzone-stats-tracker/internal/model" + "github.com/grovecj/warzone-stats-tracker/internal/repository" +) + +// MatchListResult contains paginated match data. +type MatchListResult struct { + Matches []model.Match `json:"matches"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// MatchService handles match-related business logic. +type MatchService struct { + codClient codclient.CodClient + matchRepo *repository.MatchRepo + playerRepo *repository.PlayerRepo +} + +// NewMatchService creates a new MatchService. +func NewMatchService(codClient codclient.CodClient, matchRepo *repository.MatchRepo, playerRepo *repository.PlayerRepo) *MatchService { + return &MatchService{codClient: codClient, matchRepo: matchRepo, playerRepo: playerRepo} +} + +// GetRecentMatches fetches matches from the CoD API, persists them, and returns paginated results. +func (s *MatchService) GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string, limit, offset int) (*MatchListResult, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + + // Ensure player exists in DB + player, err := s.playerRepo.GetByPlatformAndTag(ctx, platform, gamertag) + if err != nil { + return nil, err + } + if player == nil { + player, err = s.playerRepo.Upsert(ctx, platform, gamertag) + if err != nil { + return nil, err + } + } + + // Fetch from CoD API + apiMatches, err := s.codClient.GetRecentMatches(ctx, platform, gamertag, title, mode) + if err != nil { + slog.Warn("failed to fetch matches from API, falling back to DB", "error", err) + } else { + // Convert and persist + modelMatches := make([]model.Match, 0, len(apiMatches)) + for _, m := range apiMatches { + modelMatches = append(modelMatches, model.Match{ + MatchID: m.MatchID, + PlayerID: player.ID, + Mode: m.Mode, + MapName: m.Map, + Placement: m.Placement, + Kills: m.Kills, + Deaths: m.Deaths, + DamageDealt: m.DamageDealt, + DamageTaken: m.DamageTaken, + GulagResult: m.GulagResult, + MatchTime: m.MatchTime, + }) + } + if upsertErr := s.matchRepo.UpsertBatch(ctx, player.ID, modelMatches); upsertErr != nil { + slog.Warn("failed to persist matches", "error", upsertErr) + } + } + + // Read from DB with pagination + matches, err := s.matchRepo.GetByPlayerID(ctx, player.ID, limit, offset) + if err != nil { + return nil, err + } + + total, err := s.matchRepo.CountByPlayerID(ctx, player.ID) + if err != nil { + slog.Warn("failed to count matches", "error", err) + total = len(matches) + } + + return &MatchListResult{ + Matches: matches, + Total: total, + Limit: limit, + Offset: offset, + }, nil +} diff --git a/internal/service/player.go b/internal/service/player.go index ee53c3d..1aa72f1 100644 --- a/internal/service/player.go +++ b/internal/service/player.go @@ -1,3 +1,94 @@ package service -// Player business logic will be implemented in issue #9. +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/grovecj/warzone-stats-tracker/internal/codclient" + "github.com/grovecj/warzone-stats-tracker/internal/repository" +) + +// PlayerSearchResult contains the player info and a stats summary from a search. +type PlayerSearchResult struct { + PlayerID string `json:"playerId"` + Platform string `json:"platform"` + Gamertag string `json:"gamertag"` + Stats *codclient.PlayerStats `json:"stats"` +} + +// PlayerService handles player-related business logic. +type PlayerService struct { + codClient codclient.CodClient + playerRepo *repository.PlayerRepo +} + +// NewPlayerService creates a new PlayerService. +func NewPlayerService(codClient codclient.CodClient, playerRepo *repository.PlayerRepo) *PlayerService { + return &PlayerService{codClient: codClient, playerRepo: playerRepo} +} + +// SearchPlayer verifies a player exists via the CoD API, persists them, and returns search results. +func (s *PlayerService) SearchPlayer(ctx context.Context, platform, gamertag, title, mode string) (*PlayerSearchResult, error) { + if title == "" { + title = "mw" + } + if mode == "" { + mode = "wz" + } + + stats, err := s.codClient.GetPlayerStats(ctx, platform, gamertag, title, mode) + if err != nil { + return nil, err + } + + player, err := s.playerRepo.Upsert(ctx, platform, gamertag) + if err != nil { + slog.Error("failed to upsert player", "platform", platform, "gamertag", gamertag, "error", err) + return nil, err + } + + statsJSON, err := json.Marshal(stats) + if err == nil { + if saveErr := s.playerRepo.SaveStatsSnapshot(ctx, player.ID, mode, statsJSON); saveErr != nil { + slog.Warn("failed to save stats snapshot", "player_id", player.ID, "error", saveErr) + } + } + + return &PlayerSearchResult{ + PlayerID: player.ID, + Platform: platform, + Gamertag: gamertag, + Stats: stats, + }, nil +} + +// GetPlayerStats fetches player stats from the CoD API, upserts the player, and saves a snapshot. +func (s *PlayerService) GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*codclient.PlayerStats, error) { + if title == "" { + title = "mw" + } + if mode == "" { + mode = "wz" + } + + stats, err := s.codClient.GetPlayerStats(ctx, platform, gamertag, title, mode) + if err != nil { + return nil, err + } + + player, err := s.playerRepo.Upsert(ctx, platform, gamertag) + if err != nil { + slog.Error("failed to upsert player", "platform", platform, "gamertag", gamertag, "error", err) + return stats, nil // return stats even if DB write fails + } + + statsJSON, err := json.Marshal(stats) + if err == nil { + if saveErr := s.playerRepo.SaveStatsSnapshot(ctx, player.ID, mode, statsJSON); saveErr != nil { + slog.Warn("failed to save stats snapshot", "player_id", player.ID, "error", saveErr) + } + } + + return stats, nil +} diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql index 65f3659..ff74e2f 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_init.up.sql @@ -28,7 +28,7 @@ CREATE TABLE squad_members ( CREATE TABLE matches ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - match_id VARCHAR(100) NOT NULL UNIQUE, + match_id VARCHAR(100) NOT NULL, player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, mode VARCHAR(50), map_name VARCHAR(100), @@ -51,6 +51,7 @@ CREATE TABLE player_stats ( fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE UNIQUE INDEX idx_matches_match_player ON matches(match_id, player_id); CREATE INDEX idx_matches_player_id ON matches(player_id); CREATE INDEX idx_matches_match_time ON matches(match_time); CREATE INDEX idx_player_stats_player_mode ON player_stats(player_id, mode); diff --git a/web/src/components/ChartsSection.vue b/web/src/components/ChartsSection.vue new file mode 100644 index 0000000..4b38532 --- /dev/null +++ b/web/src/components/ChartsSection.vue @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + No mode data available + + + + + + + + + No match data available for trends + + + + + + diff --git a/web/src/components/MatchHistory.vue b/web/src/components/MatchHistory.vue new file mode 100644 index 0000000..3bc1b6c --- /dev/null +++ b/web/src/components/MatchHistory.vue @@ -0,0 +1,154 @@ + + + + + + + + + diff --git a/web/src/components/ModeSummaryRow.vue b/web/src/components/ModeSummaryRow.vue new file mode 100644 index 0000000..417d192 --- /dev/null +++ b/web/src/components/ModeSummaryRow.vue @@ -0,0 +1,86 @@ + + + + + + + {{ getModeLabel(String(mode)) }} + + {{ formatKD(modeStats.kdRatio) }} K/D + + + {{ formatNumber(modeStats.wins) }} W · {{ formatNumber(modeStats.matchesPlayed) }} G + + + + + + + diff --git a/web/src/components/RankedBadge.vue b/web/src/components/RankedBadge.vue new file mode 100644 index 0000000..0a4b8de --- /dev/null +++ b/web/src/components/RankedBadge.vue @@ -0,0 +1,25 @@ + + + + + + + {{ division || 'Ranked' }} + · {{ sr }} SR + + + + + + diff --git a/web/src/components/StatCard.vue b/web/src/components/StatCard.vue new file mode 100644 index 0000000..7d52b25 --- /dev/null +++ b/web/src/components/StatCard.vue @@ -0,0 +1,63 @@ + + + + + + {{ label }} + {{ value }} + + + + + diff --git a/web/src/components/StatsGrid.vue b/web/src/components/StatsGrid.vue new file mode 100644 index 0000000..9e20385 --- /dev/null +++ b/web/src/components/StatsGrid.vue @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/components/StatsHero.vue b/web/src/components/StatsHero.vue new file mode 100644 index 0000000..f8d6a35 --- /dev/null +++ b/web/src/components/StatsHero.vue @@ -0,0 +1,31 @@ + + + + + + + + + + + diff --git a/web/src/components/charts/KDGauge.vue b/web/src/components/charts/KDGauge.vue new file mode 100644 index 0000000..663638c --- /dev/null +++ b/web/src/components/charts/KDGauge.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/web/src/components/charts/ModeComparisonChart.vue b/web/src/components/charts/ModeComparisonChart.vue new file mode 100644 index 0000000..95b147b --- /dev/null +++ b/web/src/components/charts/ModeComparisonChart.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/web/src/components/charts/PerformanceTrend.vue b/web/src/components/charts/PerformanceTrend.vue new file mode 100644 index 0000000..d09f04a --- /dev/null +++ b/web/src/components/charts/PerformanceTrend.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/components/charts/PlacementChart.vue b/web/src/components/charts/PlacementChart.vue new file mode 100644 index 0000000..808c43e --- /dev/null +++ b/web/src/components/charts/PlacementChart.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/web/src/components/charts/RadarChart.vue b/web/src/components/charts/RadarChart.vue new file mode 100644 index 0000000..072dedb --- /dev/null +++ b/web/src/components/charts/RadarChart.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts index 50701fb..2c709d0 100644 --- a/web/src/stores/index.ts +++ b/web/src/stores/index.ts @@ -1,19 +1,48 @@ import { ref } from 'vue' import { defineStore } from 'pinia' -import type { PlayerStats, Match } from '@/types' +import type { PlayerStats, Match, PlayerSearchResult, MatchListResult } from '@/types' export const usePlayerStore = defineStore('player', () => { - const loading = ref(false) - const error = ref(null) + const statsLoading = ref(false) + const matchesLoading = ref(false) + const statsError = ref(null) + const matchesError = ref(null) const stats = ref(null) const matches = ref([]) + const matchesTotal = ref(0) - async function fetchStats(platform: string, gamertag: string, mode = 'wz') { - loading.value = true - error.value = null + async function searchPlayer( + platform: string, + gamertag: string, + title = 'mw', + mode = 'wz', + ): Promise { + const tag = encodeURIComponent(gamertag) + const res = await fetch( + `/api/v1/players/search?platform=${encodeURIComponent(platform)}&gamertag=${tag}&title=${encodeURIComponent(title)}&mode=${encodeURIComponent(mode)}`, + ) + if (!res.ok) { + const text = await res.text() + let message = 'Failed to search player' + try { + const body = JSON.parse(text) + message = body.message || message + } catch { + // Response was not JSON + } + throw new Error(message) + } + return await res.json() + } + + async function fetchStats(platform: string, gamertag: string, title = 'mw', mode = 'wz') { + statsLoading.value = true + statsError.value = null try { const tag = encodeURIComponent(gamertag) - const res = await fetch(`/api/v1/players/${platform}/${tag}/stats?mode=${encodeURIComponent(mode)}`) + const res = await fetch( + `/api/v1/players/${platform}/${tag}/stats?title=${encodeURIComponent(title)}&mode=${encodeURIComponent(mode)}`, + ) if (!res.ok) { const text = await res.text() let message = 'Failed to fetch stats' @@ -27,18 +56,20 @@ export const usePlayerStore = defineStore('player', () => { } stats.value = await res.json() } catch (e: unknown) { - error.value = e instanceof Error ? e.message : 'An unexpected error occurred' + statsError.value = e instanceof Error ? e.message : 'An unexpected error occurred' } finally { - loading.value = false + statsLoading.value = false } } - async function fetchMatches(platform: string, gamertag: string) { - loading.value = true - error.value = null + async function fetchMatches(platform: string, gamertag: string, title = 'mw', mode = 'wz', limit = 20, offset = 0) { + matchesLoading.value = true + matchesError.value = null try { const tag = encodeURIComponent(gamertag) - const res = await fetch(`/api/v1/players/${platform}/${tag}/matches`) + const res = await fetch( + `/api/v1/players/${platform}/${tag}/matches?title=${encodeURIComponent(title)}&mode=${encodeURIComponent(mode)}&limit=${limit}&offset=${offset}`, + ) if (!res.ok) { const text = await res.text() let message = 'Failed to fetch matches' @@ -50,15 +81,28 @@ export const usePlayerStore = defineStore('player', () => { } throw new Error(message) } - matches.value = await res.json() + const result: MatchListResult = await res.json() + matches.value = result.matches ?? [] + matchesTotal.value = result.total } catch (e: unknown) { - error.value = e instanceof Error ? e.message : 'An unexpected error occurred' + matchesError.value = e instanceof Error ? e.message : 'An unexpected error occurred' } finally { - loading.value = false + matchesLoading.value = false } } - return { loading, error, stats, matches, fetchStats, fetchMatches } + return { + statsLoading, + matchesLoading, + statsError, + matchesError, + stats, + matches, + matchesTotal, + searchPlayer, + fetchStats, + fetchMatches, + } }) export const useSquadStore = defineStore('squad', () => { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 74f8349..5f2f417 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -18,21 +18,65 @@ export interface PlayerStats { topTwentyFive: number assists: number damageDone: number + modeBreakdown?: Record +} + +export interface ModeStats { + kills: number + deaths: number + kdRatio: number + wins: number + losses: number + matchesPlayed: number + scorePerMin: number + timePlayed: number + topFive: number + topTen: number + topTwentyFive: number +} + +export interface Player { + id: string + platform: string + gamertag: string + activisionId?: string + lastFetchedAt?: string + createdAt: string + updatedAt: string +} + +export interface PlayerSearchResult { + playerId: string + platform: string + gamertag: string + stats: PlayerStats } export interface Match { - matchID: string + id?: string + matchId?: string + matchID?: string + playerId?: string mode: string - map: string + map?: string + mapName?: string placement: number kills: number deaths: number - kdRatio: number + kdRatio?: number damageDealt: number damageTaken: number gulagResult: string - duration: number + duration?: number matchTime: string + createdAt?: string +} + +export interface MatchListResult { + matches: Match[] + total: number + limit: number + offset: number } export interface Squad { diff --git a/web/src/utils/echarts.ts b/web/src/utils/echarts.ts new file mode 100644 index 0000000..294db0a --- /dev/null +++ b/web/src/utils/echarts.ts @@ -0,0 +1,52 @@ +import { use } from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { RadarChart, GaugeChart, BarChart, PieChart, LineChart } from 'echarts/charts' +import { + TitleComponent, + TooltipComponent, + LegendComponent, + GridComponent, + RadarComponent, +} from 'echarts/components' +import VChart from 'vue-echarts' + +use([ + CanvasRenderer, + RadarChart, + GaugeChart, + BarChart, + PieChart, + LineChart, + TitleComponent, + TooltipComponent, + LegendComponent, + GridComponent, + RadarComponent, +]) + +export const CHART_COLORS = { + accent: '#00e5ff', + accentHover: '#18ffff', + bg: '#0a0a0f', + cardBg: '#12121a', + text: '#e0e0e0', + textMuted: '#707088', + border: '#2a2a3a', + red: '#ff4d4f', + yellow: '#faad14', + green: '#52c41a', + cyan: '#00e5ff', + blue: '#1890ff', + purple: '#722ed1', + orange: '#fa8c16', + series: ['#00e5ff', '#ff4d4f', '#52c41a', '#faad14', '#1890ff', '#722ed1'], +} + +export const DARK_THEME = { + backgroundColor: 'transparent', + textStyle: { color: CHART_COLORS.text }, + title: { textStyle: { color: CHART_COLORS.text } }, + legend: { textStyle: { color: CHART_COLORS.textMuted } }, +} + +export { VChart } diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts new file mode 100644 index 0000000..1c09806 --- /dev/null +++ b/web/src/utils/format.ts @@ -0,0 +1,26 @@ +const numberFormatter = new Intl.NumberFormat('en-US') + +export function formatNumber(n: number): string { + return numberFormatter.format(n) +} + +export function formatKD(kd: number): string { + return kd.toFixed(2) +} + +export function formatTimePlayed(seconds: number): string { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return `${hours}h ${minutes}m` +} + +export function formatPercent(pct: number): string { + return `${(pct * 100).toFixed(1)}%` +} + +export function kdColorClass(kd: number): string { + if (kd >= 2.0) return 'kd-excellent' + if (kd >= 1.2) return 'kd-good' + if (kd >= 0.8) return 'kd-average' + return 'kd-poor' +} diff --git a/web/src/utils/modes.ts b/web/src/utils/modes.ts new file mode 100644 index 0000000..8b247b0 --- /dev/null +++ b/web/src/utils/modes.ts @@ -0,0 +1,32 @@ +export interface ModeInfo { + key: string + label: string + icon: string +} + +export const MODES: ModeInfo[] = [ + { key: 'wz', label: 'All Modes', icon: 'GI' }, + { key: 'br', label: 'Battle Royale', icon: 'BR' }, + { key: 'rebirth', label: 'Resurgence', icon: 'RS' }, + { key: 'ranked', label: 'Ranked', icon: 'RK' }, + { key: 'plunder', label: 'Plunder', icon: 'PL' }, +] + +export const MODE_LABELS: Record = { + br_all: 'Battle Royale', + br_brsolo: 'BR Solos', + br_brduos: 'BR Duos', + br_brtrios: 'BR Trios', + br_brquads: 'BR Quads', + br_rebirth_rbrthduos: 'Resurgence Duos', + br_rebirth_rbrthtrios: 'Resurgence Trios', + br_rebirth_rbrthquads: 'Resurgence Quads', + br_dmz: 'Plunder', + br_plnbld: 'Blood Money', + br_kingslayer_kingsltrios: 'King Slayer', + br_mini_miniroyale: 'Mini Royale', +} + +export function getModeLabel(mode: string): string { + return MODE_LABELS[mode] || mode.replace(/^br_/, '').replace(/_/g, ' ') +} diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index f2ea46f..6d5357e 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -1,11 +1,26 @@ @@ -38,6 +134,14 @@ function search() { + + {{ searchError }} + + - Search Player + + Search Player + + + + Recent Searches + + + {{ recent.gamertag }} + + {{ platformLabels[recent.platform] || recent.platform }} + {{ recent.game ? `/ ${gameLabels[recent.game] || recent.game}` : '' }} + + + + @@ -88,4 +221,26 @@ function search() { width: 100%; max-width: 480px; } + +.recent-searches { + margin-top: 32px; + width: 100%; + max-width: 480px; + text-align: center; +} + +.recent-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + display: block; + margin-bottom: 12px; +} + +.recent-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} diff --git a/web/src/views/PlayerView.vue b/web/src/views/PlayerView.vue index 21bb9ec..db94404 100644 --- a/web/src/views/PlayerView.vue +++ b/web/src/views/PlayerView.vue @@ -1,23 +1,118 @@ - - {{ gamertag }} - ({{ platform }}) - - - - Player stats will be displayed here (issue #10) - - + + + + {{ gamertag }} + ({{ platform }}) + + + + + + {{ playerStore.statsError }} + + + {{ playerStore.matchesError }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Match History + + @@ -27,4 +122,35 @@ const gamertag = route.params.gamertag as string flex-direction: column; gap: 24px; } + +.player-header { + display: flex; + align-items: center; + gap: 12px; +} + +.section-header { + margin-top: 8px; +} + +.skeleton-hero { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.skeleton-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +@media (max-width: 640px) { + .skeleton-hero { + grid-template-columns: 1fr; + } + .skeleton-grid { + grid-template-columns: repeat(2, 1fr); + } +}