A self-hosted REST API that tracks World of Tanks player statistics over time. It proxies Wargaming's public API for live profile/vehicle data and adds a local snapshot history so you can compute trailing-window deltas (7/14/30 days) for battle stats and the WN8 efficiency rating. The repo also contains a React web client in client/ that talks to the API via same-origin /api/*.
Built with Express 5, TypeScript, Prisma 7, and PostgreSQL; the client is React 19, react-router 8, and Vite 8.
- Player search & profile: proxy
account/listandaccount/infowith a TTL read cache. - Trailing-window stat deltas:
current − nearestPastover 7/14/30 days from locally captured snapshots, with a capture-on-query booster (5-min dedup). - WN8 rating: overall current WN8 plus 7/14/30-day deltas, computed from per-tank counters vs. XVM expected values.
- Vehicle encyclopedia: reference data ingested from the WG encyclopedia.
- Daily capture scheduler: optional job that snapshots all tracked accounts (off by default).
- Uniform JSON envelope: every response (success or error) uses the same
{ status, ... }shape. - Web client: the React dashboard in
client/covers player search, profile, trailing-window stats, WN8, and an admin page.
Server commands run from server/; client commands from client/.
cd server
cp .env.example .env # fill in DATABASE_URL + WARGAMING_APPLICATION_ID
npm install
npx prisma migrate dev # apply the schema
npm run sync # one-shot: ingest vehicle encyclopedia + WN8 expected values
npm run dev # start dev server (default PORT=3001)# in a second terminal, start the web client
cd client
npm install
npm run dev # http://localhost:5173, proxies /api -> localhost:3001Get a Wargaming application ID at https://developers.wargaming.net/applications/.
?forceRefresh=true bypasses the Wargaming read cache on proxy routes.
| Method | Path | Query | Purpose |
|---|---|---|---|
GET |
/health |
- | DB connectivity probe; 503 if unreachable |
GET |
/players/search |
search (req), forceRefresh |
Search accounts by nickname |
GET |
/players/:accountId |
forceRefresh |
Player profile; enrolls the account for capture |
GET |
/players/:accountId/vehicles |
forceRefresh |
Per-tank stats; enrolls on a non-empty result |
GET |
/players/:accountId/stats |
range (req, 7|14|30) |
Trailing-window stat delta |
GET |
/players/:accountId/stats/summary |
ranges (default 7,14,30) |
All three windows in one call |
GET |
/players/:accountId/wn8 |
range (opt, 7|14|30) |
Overall current WN8 (no range), or a window delta |
GET |
/players/:accountId/wn8/summary |
ranges (default 7,14,30) |
All three WN8 windows in one call |
:accountId must be a positive integer. ?range/?ranges accept only 7, 14, 30.
| Method | Path | Purpose |
|---|---|---|
POST |
/admin/snapshots/run |
Run a manual capture of all tracked accounts |
POST |
/admin/vehicles/refresh |
Re-ingest the WG vehicle encyclopedia |
POST |
/admin/wn8/refresh-expected |
Re-ingest XVM WN8 expected values |
Fail-closed: if ADMIN_TOKEN is unset, every /admin/* route returns 503.
The envelope code becomes the HTTP status only for 5xx and INSUFFICIENT_HISTORY (422); validation errors keep envelope code 402 but stay HTTP 400. field/value appear on validation errors only.
The web client (client/) is a React 19 + react-router 8 + Vite 8 + TypeScript app: player search, profile, trailing-window stats, WN8, and an admin page.
- Dev:
npm run devfromclient/serves on port 5173. Vite proxies same-origin/api/*toWOT_API_TARGET(defaulthttp://localhost:3001) and strips the/apiprefix, so no CORS setup is needed in dev. - Prod: serve the built UI from the API's origin, or put a reverse proxy in front that maps
/api/*to the API. SetVITE_API_BASE_URLto an absolute URL only if you're not using the proxy (e.g. prod with CORS enabled). VITE_ADMIN_TOKENsupplies the admin page'sX-Admin-Token.- All API access goes through
client/src/router.ts(api,ApiError, payload types). Seeclient/AGENTS.mdfor frontend agent guidance.
Copy server/.env.example to server/.env. See server/AGENTS.md for agent guidance.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DATABASE_URL |
yes | - | Postgres connection string |
DIRECT_DATABASE_URL |
no | unset | Direct (non-pooled) URL for migrations; falls back to DATABASE_URL |
WARGAMING_APPLICATION_ID |
yes | - | WG API application ID; server won't boot without it |
WARGAMING_REALM |
no | eu |
One of eu, na, asia, ru |
WARGAMING_CACHE_TTL_SECONDS |
no | 3600 |
TTL for account/list cache |
WARGAMING_INFO_CACHE_TTL_SECONDS |
no | 3600 |
TTL for account/info cache |
WARGAMING_VEHICLES_CACHE_TTL_SECONDS |
no | 3600 |
TTL for tanks/stats cache |
PORT |
no | 3001 |
HTTP listen port |
SNAPSHOT_JOB_ENABLED |
no | off | true enables the daily capture interval |
ADMIN_TOKEN |
no | unset | Shared secret for /admin/*; unset → all admin routes return 503 |
Realms: eu → api.worldoftanks.eu, na → api.worldoftanks.com, asia → api.worldoftanks.asia, ru → api.worldoftanks.ru.
Server (from server/):
npm run dev # tsx watch, auto-loads .env; default PORT=3001
npm run build # tsc --build
npm run sync # one-shot: WG vehicle encyclopedia + XVM WN8 expected values
npx prisma migrate dev --name <name> # create+apply a migration
npx prisma generate # regenerate client into generated/prisma
npx tsc --noEmit # typecheck
npx eslint . | npx prettier --check . # lint / format checkClient (from client/):
npm run dev # vite dev server on :5173
npm run build # tsc + vite build
npm run preview # preview the production buildSnapshots & deltas. Enrollment is implicit: querying a player's profile or vehicles upserts a TrackedAccount once Wargaming confirms the account exists. The daily capture job (and the capture-on-query booster) write snapshot rows. A 7/14/30-day delta is current − nearestPast; when no snapshot is old enough it falls back to the oldest available, and INSUFFICIENT_HISTORY (422) is returned only when no past snapshot exists. Snapshots older than 45 days are GC'd; identical counters are skipped.
WN8. Per-tank counters are stored as one JSON blob per capture. Trailing-window WN8 is computed from per-tank current − nearestPast deltas, then battle-weighted. Tanks missing from VehicleExpectedValue are excluded. Expected values come from the XVM dataset.
server/
src/
app.ts # Express app, all routes inline
lib/
prisma.ts # singleton PrismaClient (@prisma/adapter-pg)
http.ts # envelope helpers: sendResult / sendApiError
middleware.ts # Express 5 error handler -> JSON envelope
adminAuth.ts # X-Admin-Token guard (fail-closed)
wargaming.ts # TTL-cached proxies for WG account/tanks/encyclopedia
stats.ts # trailing-window stat deltas + snapshots
wn8.ts # WN8 current/delta/summary
vehicles.ts # vehicle encyclopedia + XVM expected-value ingestion
scheduler.ts # daily capture job (off by default)
scripts/sync.ts # npm run sync entrypoint
prisma/schema.prisma
generated/prisma/ # gitignored; generated by `prisma generate`
Dockerfile # multi-stage build, runs compiled JS (no tsx at runtime), EXPOSE 5001
client/
src/
router.ts # typed API client (api, ApiError, payload types)
pages/ # IndexPage (search), PlayerPage (stats/WN8), AdminPage
main.tsx, style.css
vite.config.ts # dev proxy /api -> WOT_API_TARGET (default :3001)
AGENTS.md # frontend agent guide
Prisma 7 with prisma.config.ts (loads dotenv/config so the CLI picks up .env). Generator output generated/prisma is gitignored; the app imports the generated client directly. Stale dir breaks the build. If prisma migrate dev doesn't regenerate the client, run npx prisma generate.
Express 5, TypeScript 5 (strict, ESM), Prisma 7 with @prisma/adapter-pg, PostgreSQL. tsx for dev, tsc for build. Prettier (no semicolons, single quotes, 2-space indent), ESLint with prefer-const.