Skip to content

Commit 5f3a12e

Browse files
grovecjclaude
andauthored
Implement Phase 2: Player search, stats, matches, charts, mode breakdown (#26)
## Summary - **Player Search (#9)**: Backend service + handler for `GET /api/v1/players/search`, frontend search-then-navigate flow with error alerts and recent searches in localStorage - **Lifetime Stats Display (#10)**: StatCard, StatsHero (K/D, Wins, Kills), and StatsGrid (12 secondary stats) components with responsive CSS Grid layouts - **Stats Visualizations (#11)**: Tree-shaken ECharts setup with 5 chart components — Radar, K/D Gauge, Mode Comparison Bar, Placement Donut, Performance Trend Line - **Match History (#12)**: Backend service + handler for `GET /api/v1/players/{platform}/{gamertag}/matches`, NDataTable with sorting, mode filter, win highlighting, and gulag badges - **Game Mode Breakdown (#13)**: Mode tabs (NTabs segment), ModeSummaryRow with click-to-switch cards, ModeBreakdown parsing from single API response ### Bugfixes (commit 2) - Fix CoD API base URL (`my.callofduty.com` → `www.callofduty.com` redirects to store) - Add NoRedirectPolicy + doRequest helper to detect 3xx as expired tokens - Parse 200 OK responses with `{"status":"error"}` JSON bodies into sentinel errors - Fix `profileResponse.Level/Prestige` type mismatch (API returns float, struct expected int) - Fix nullable `activision_id` column scan crash (`string` → `*string`) - Add error logging in writeAPIError default case ### Multi-title support (commit 3) - Add `title` and `mode` query params to all API endpoints (defaults: `mw`/`wz`) - Support Warzone 1 (`mw`/`wz`), Warzone 2 (`mw2`/`wz2`), MW MP (`mw`/`mp`), MW2 MP (`mw2`/`mp`) - Use `id` lookup type for `uno` (Activision) platform per API spec - Add game selector dropdown to HomeView - Thread title/mode through cache, services, handlers, store, and PlayerView ## Test plan - [ ] `go build ./...` and `go vet ./...` pass - [ ] `cd web && npm run build-only && npm run type-check` pass - [ ] `cd web && npx oxlint . && npx eslint . --cache` pass - [ ] Start dev server with valid `COD_SSO_TOKEN` + PostgreSQL - [ ] Search for a known gamertag on HomeView — verify no 500 errors - [ ] Test game selector: switch between WZ, WZ2, MW MP, MW2 MP - [ ] Verify redirect to PlayerView with stats cards rendering - [ ] Verify match history table populates - [ ] Switch mode tabs and verify stats update - [ ] Check charts render with correct data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 52ecc87 commit 5f3a12e

32 files changed

Lines changed: 1857 additions & 98 deletions

cmd/server/main.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import (
1717
"github.com/grovecj/warzone-stats-tracker/internal/config"
1818
"github.com/grovecj/warzone-stats-tracker/internal/database"
1919
"github.com/grovecj/warzone-stats-tracker/internal/handler"
20+
"github.com/grovecj/warzone-stats-tracker/internal/repository"
2021
"github.com/grovecj/warzone-stats-tracker/internal/router"
22+
"github.com/grovecj/warzone-stats-tracker/internal/service"
2123
"github.com/grovecj/warzone-stats-tracker/web"
2224
)
2325

@@ -62,8 +64,18 @@ func main() {
6264
}
6365
}
6466

67+
// Repositories
68+
playerRepo := repository.NewPlayerRepo(pool)
69+
matchRepo := repository.NewMatchRepo(pool)
70+
71+
// Services
72+
playerService := service.NewPlayerService(cachedAPI, playerRepo)
73+
matchService := service.NewMatchService(cachedAPI, matchRepo, playerRepo)
74+
6575
// Handlers
6676
adminHandler := handler.NewAdminHandler(cachedAPI)
77+
playerHandler := handler.NewPlayerHandler(playerService)
78+
matchHandler := handler.NewMatchHandler(matchService)
6779

6880
// Router
6981
rawOrigins := strings.Split(cfg.CORSAllowedOrigins, ",")
@@ -74,8 +86,10 @@ func main() {
7486
}
7587
}
7688
mux := router.New(origins, staticFS, router.Deps{
77-
AdminHandler: adminHandler,
78-
AdminAPIKey: cfg.AdminAPIKey,
89+
AdminHandler: adminHandler,
90+
PlayerHandler: playerHandler,
91+
MatchHandler: matchHandler,
92+
AdminAPIKey: cfg.AdminAPIKey,
7993
})
8094

8195
srv := &http.Server{

internal/cache/cache.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ func New(inner codclient.CodClient, cfg Config) *CachedClient {
6060
return c
6161
}
6262

63-
func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*codclient.PlayerStats, error) {
64-
key := fmt.Sprintf("stats:%s:%s:%s", platform, gamertag, mode)
63+
func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*codclient.PlayerStats, error) {
64+
key := fmt.Sprintf("stats:%s:%s:%s:%s", platform, gamertag, title, mode)
6565

6666
if val, hit := c.get(key); hit {
6767
slog.Debug("cache hit", "key", key)
6868
return val.(*codclient.PlayerStats), nil
6969
}
7070

71-
stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, mode)
71+
stats, err := c.inner.GetPlayerStats(ctx, platform, gamertag, title, mode)
7272
if err != nil {
7373
// Only serve stale data for transient errors (API down, rate limited)
7474
if isTransientError(err) {
@@ -84,15 +84,15 @@ func (c *CachedClient) GetPlayerStats(ctx context.Context, platform, gamertag, m
8484
return stats, nil
8585
}
8686

87-
func (c *CachedClient) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]codclient.Match, error) {
88-
key := fmt.Sprintf("matches:%s:%s", platform, gamertag)
87+
func (c *CachedClient) GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]codclient.Match, error) {
88+
key := fmt.Sprintf("matches:%s:%s:%s:%s", platform, gamertag, title, mode)
8989

9090
if val, hit := c.get(key); hit {
9191
slog.Debug("cache hit", "key", key)
9292
return val.([]codclient.Match), nil
9393
}
9494

95-
matches, err := c.inner.GetRecentMatches(ctx, platform, gamertag)
95+
matches, err := c.inner.GetRecentMatches(ctx, platform, gamertag, title, mode)
9696
if err != nil {
9797
if isTransientError(err) {
9898
if val, ok := c.getStale(key); ok {

internal/codclient/client.go

Lines changed: 160 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,17 @@ import (
77
"log/slog"
88
"net/http"
99
"net/url"
10+
"strings"
1011
"sync"
1112
"time"
1213

1314
"resty.dev/v3"
1415
)
1516

16-
const (
17-
defaultTitle = "mw" // Modern Warfare / Warzone title code
18-
)
19-
2017
// CodClient defines the interface for interacting with the Call of Duty API.
2118
type CodClient interface {
22-
GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error)
23-
GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error)
19+
GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*PlayerStats, error)
20+
GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]Match, error)
2421
UpdateToken(newToken string)
2522
}
2623

@@ -41,6 +38,7 @@ func New(baseURL, ssoToken string) CodClient {
4138
c.SetRetryMaxWaitTime(5 * time.Second)
4239
c.SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
4340
c.SetHeader("Accept", "application/json")
41+
c.SetRedirectPolicy(resty.NoRedirectPolicy())
4442

4543
return &client{http: c, baseURL: baseURL, token: ssoToken}
4644
}
@@ -51,25 +49,20 @@ func (c *client) authCookie() *http.Cookie {
5149
return &http.Cookie{Name: "ACT_SSO_COOKIE", Value: c.token}
5250
}
5351

54-
func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, mode string) (*PlayerStats, error) {
52+
func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, title, mode string) (*PlayerStats, error) {
53+
if title == "" {
54+
title = "mw"
55+
}
5556
if mode == "" {
5657
mode = "wz"
5758
}
5859

5960
encodedTag := url.PathEscape(gamertag)
6061
endpoint := fmt.Sprintf("/stats/cod/v1/title/%s/platform/%s/gamer/%s/profile/type/%s",
61-
defaultTitle, platform, encodedTag, mode)
62+
title, platform, encodedTag, mode)
6263

63-
resp, err := c.http.R().
64-
SetContext(ctx).
65-
SetCookie(c.authCookie()).
66-
Get(endpoint)
64+
resp, err := c.doRequest(ctx, endpoint)
6765
if err != nil {
68-
slog.Error("cod api request failed", "endpoint", endpoint, "error", err)
69-
return nil, ErrAPIUnavailable
70-
}
71-
72-
if err := c.checkResponse(resp); err != nil {
7366
return nil, err
7467
}
7568

@@ -82,21 +75,20 @@ func (c *client) GetPlayerStats(ctx context.Context, platform, gamertag, mode st
8275
return stats, nil
8376
}
8477

85-
func (c *client) GetRecentMatches(ctx context.Context, platform, gamertag string) ([]Match, error) {
78+
func (c *client) GetRecentMatches(ctx context.Context, platform, gamertag, title, mode string) ([]Match, error) {
79+
if title == "" {
80+
title = "mw"
81+
}
82+
if mode == "" {
83+
mode = "wz"
84+
}
85+
8686
encodedTag := url.PathEscape(gamertag)
87-
endpoint := fmt.Sprintf("/crm/cod/v2/title/%s/platform/%s/gamer/%s/matches/wz/start/0/end/0/details",
88-
defaultTitle, platform, encodedTag)
87+
endpoint := fmt.Sprintf("/crm/cod/v2/title/%s/platform/%s/gamer/%s/matches/%s/start/0/end/0/details",
88+
title, platform, encodedTag, mode)
8989

90-
resp, err := c.http.R().
91-
SetContext(ctx).
92-
SetCookie(c.authCookie()).
93-
Get(endpoint)
90+
resp, err := c.doRequest(ctx, endpoint)
9491
if err != nil {
95-
slog.Error("cod api request failed", "endpoint", endpoint, "error", err)
96-
return nil, ErrAPIUnavailable
97-
}
98-
99-
if err := c.checkResponse(resp); err != nil {
10092
return nil, err
10193
}
10294

@@ -141,9 +133,52 @@ func (c *client) UpdateToken(newToken string) {
141133
slog.Info("cod api sso token updated")
142134
}
143135

136+
// doRequest performs a GET request, handling redirect errors as expired tokens.
137+
func (c *client) doRequest(ctx context.Context, endpoint string) (*resty.Response, error) {
138+
resp, err := c.http.R().
139+
SetContext(ctx).
140+
SetCookie(c.authCookie()).
141+
Get(endpoint)
142+
if err != nil {
143+
// resty returns an error on redirect when NoRedirectPolicy is set,
144+
// but the response is still populated
145+
if resp != nil && resp.StatusCode() >= 300 && resp.StatusCode() < 400 {
146+
slog.Warn("cod api redirected, token likely expired",
147+
"status", resp.StatusCode(),
148+
"location", resp.Header().Get("Location"))
149+
return nil, ErrTokenExpired
150+
}
151+
slog.Error("cod api request failed", "endpoint", endpoint, "error", err)
152+
return nil, ErrAPIUnavailable
153+
}
154+
155+
if err := c.checkResponse(resp); err != nil {
156+
return nil, err
157+
}
158+
return resp, nil
159+
}
160+
144161
func (c *client) checkResponse(resp *resty.Response) error {
145162
switch resp.StatusCode() {
146163
case http.StatusOK:
164+
body := resp.String()
165+
// CoD API sometimes returns 200 with HTML (login page) instead of JSON
166+
if len(body) > 0 && body[0] == '<' {
167+
slog.Error("cod api returned html instead of json",
168+
"content_type", resp.Header().Get("Content-Type"),
169+
"body_prefix", body[:min(200, len(body))])
170+
return ErrTokenExpired
171+
}
172+
// CoD API returns 200 with {"status":"error"} for business-logic errors
173+
var envelope struct {
174+
Status string `json:"status"`
175+
Data struct {
176+
Message string `json:"message"`
177+
} `json:"data"`
178+
}
179+
if json.Unmarshal([]byte(body), &envelope) == nil && envelope.Status == "error" {
180+
return c.mapAPIError(envelope.Data.Message)
181+
}
147182
return nil
148183
case http.StatusUnauthorized:
149184
return ErrTokenExpired
@@ -153,20 +188,46 @@ func (c *client) checkResponse(resp *resty.Response) error {
153188
return ErrPlayerNotFound
154189
case http.StatusTooManyRequests:
155190
return ErrRateLimited
191+
case http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect:
192+
// CoD API redirects to login/store page when token is expired
193+
slog.Warn("cod api redirected, token likely expired",
194+
"status", resp.StatusCode(),
195+
"location", resp.Header().Get("Location"))
196+
return ErrTokenExpired
156197
default:
157198
if resp.StatusCode() >= 500 {
158199
return ErrAPIUnavailable
159200
}
160-
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode(), resp.String())
201+
body := resp.String()
202+
slog.Error("cod api unexpected status", "status", resp.StatusCode(),
203+
"body_prefix", body[:min(200, len(body))])
204+
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode(), body)
205+
}
206+
}
207+
208+
// mapAPIError converts CoD API error messages into sentinel errors.
209+
func (c *client) mapAPIError(msg string) error {
210+
slog.Warn("cod api returned error", "message", msg)
211+
switch {
212+
case strings.Contains(msg, "not authenticated"):
213+
return ErrTokenExpired
214+
case strings.Contains(msg, "not allowed"):
215+
return ErrPlayerNotFound
216+
case strings.Contains(msg, "user not found"):
217+
return ErrPlayerNotFound
218+
case strings.Contains(msg, "rate limit"):
219+
return ErrRateLimited
220+
default:
221+
return fmt.Errorf("cod api error: %s", msg)
161222
}
162223
}
163224

164225
func (c *client) mapProfileToStats(resp profileResponse, platform, gamertag string) *PlayerStats {
165226
stats := &PlayerStats{
166227
Platform: platform,
167228
Gamertag: gamertag,
168-
Level: resp.Data.Level,
169-
Prestige: resp.Data.Prestige,
229+
Level: int(resp.Data.Level),
230+
Prestige: int(resp.Data.Prestige),
170231
}
171232

172233
if props, ok := resp.Data.Lifetime.All["properties"]; ok {
@@ -187,5 +248,72 @@ func (c *client) mapProfileToStats(resp profileResponse, platform, gamertag stri
187248
stats.DamageDone = int(props.DamageDone)
188249
}
189250

251+
// Parse per-mode breakdown from resp.Data.Lifetime.Mode
252+
stats.ModeBreakdown = c.parseModeBreakdown(resp.Data.Lifetime.Mode)
253+
190254
return stats
191255
}
256+
257+
// parseModeBreakdown extracts per-mode stats from the API's Mode map.
258+
func (c *client) parseModeBreakdown(modeData map[string]any) map[string]ModeStats {
259+
if len(modeData) == 0 {
260+
return nil
261+
}
262+
263+
breakdown := make(map[string]ModeStats, len(modeData))
264+
for modeName, modeVal := range modeData {
265+
modeMap, ok := modeVal.(map[string]any)
266+
if !ok {
267+
continue
268+
}
269+
propsVal, ok := modeMap["properties"]
270+
if !ok {
271+
continue
272+
}
273+
props, ok := propsVal.(map[string]any)
274+
if !ok {
275+
continue
276+
}
277+
278+
breakdown[modeName] = ModeStats{
279+
Kills: toInt(props["kills"]),
280+
Deaths: toInt(props["deaths"]),
281+
KDRatio: toFloat(props["kdRatio"]),
282+
Wins: toInt(props["wins"]),
283+
Losses: toInt(props["losses"]),
284+
MatchesPlayed: toInt(props["matchesPlayed"]),
285+
ScorePerMin: toFloat(props["scorePerMinute"]),
286+
TimePlayed: toInt(props["timePlayed"]),
287+
TopFive: toInt(props["topFive"]),
288+
TopTen: toInt(props["topTen"]),
289+
TopTwentyFive: toInt(props["topTwentyFive"]),
290+
}
291+
}
292+
293+
if len(breakdown) == 0 {
294+
return nil
295+
}
296+
return breakdown
297+
}
298+
299+
func toFloat(v any) float64 {
300+
switch n := v.(type) {
301+
case float64:
302+
return n
303+
case int:
304+
return float64(n)
305+
default:
306+
return 0
307+
}
308+
}
309+
310+
func toInt(v any) int {
311+
switch n := v.(type) {
312+
case float64:
313+
return int(n)
314+
case int:
315+
return n
316+
default:
317+
return 0
318+
}
319+
}

internal/codclient/types.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,24 @@ type PlayerStats struct {
2121
TopFive int `json:"topFive"`
2222
TopTen int `json:"topTen"`
2323
TopTwentyFive int `json:"topTwentyFive"`
24-
Assists int `json:"assists"`
25-
DamageDone int `json:"damageDone"`
24+
Assists int `json:"assists"`
25+
DamageDone int `json:"damageDone"`
26+
ModeBreakdown map[string]ModeStats `json:"modeBreakdown,omitempty"`
27+
}
28+
29+
// ModeStats represents per-mode statistics from the CoD API.
30+
type ModeStats struct {
31+
Kills int `json:"kills"`
32+
Deaths int `json:"deaths"`
33+
KDRatio float64 `json:"kdRatio"`
34+
Wins int `json:"wins"`
35+
Losses int `json:"losses"`
36+
MatchesPlayed int `json:"matchesPlayed"`
37+
ScorePerMin float64 `json:"scorePerMin"`
38+
TimePlayed int `json:"timePlayed"`
39+
TopFive int `json:"topFive"`
40+
TopTen int `json:"topTen"`
41+
TopTwentyFive int `json:"topTwentyFive"`
2642
}
2743

2844
// Match represents a single match from the CoD API.
@@ -58,8 +74,8 @@ type profileResponse struct {
5874
All map[string]statsBlock `json:"all"`
5975
Mode map[string]any `json:"mode"`
6076
} `json:"lifetime"`
61-
Level int `json:"level"`
62-
Prestige int `json:"prestige"`
77+
Level float64 `json:"level"`
78+
Prestige float64 `json:"prestige"`
6379
} `json:"data"`
6480
}
6581

0 commit comments

Comments
 (0)