Skip to content

Repository files navigation

VCT 2026 — Season Stats

A static stats dashboard for VCT 2026 tier-1 Valorant esports, built from scraped vlr.gg data. No backend — everything is a JSON file baked in at build time, served as a static site.

Live at: deployed via Cloudflare Pages, connected to this repo's main branch.

What's here

Six views, plus per-player and per-team profile pages:

View What it shows
Overview Season KPIs (events, matches, maps, rounds, players, teams) and top-10 leaderboards
Players Every player's Rating, ACS, K/D, KAST, ADR, HS%, kills/deaths/multi-kills/clutches, sortable and searchable
Teams Match/map records, win rates, pistol win rate, average player rating
Agents Pick rates and map win rates, filterable by Region → Stage → Phase → Week/Round (the last one is multi-select)
Economy Buy-tier distribution (eco/semi-eco/semi-buy/full-buy) and win rates by tier
Player / Team profiles Click any name anywhere on the site to open a full breakdown
Graphics Build shareable HLTV-style stat cards — pick players or teams, pick a stat (rating, ACS, multi-kills/24R, round win%, pistol win%, …), filter the sample, tune minimum rounds/maps and top-N with live preview, then export a 2160px PNG

Most pages that touch China have an explicit toggle for known VLR data gaps (see Data caveats below), and most also have a toggle to fold in Esports World Cup (EWC) 2026 results alongside the main VCT season.

Tech stack

  • Vite + React (function components, hooks — no class components)
  • React Router for client-side routing (/players/:name, /teams/:name, etc.)
  • Tailwind CSS, design tokens in tailwind.config.js — colors and spacing pulled from a real reference site's stylesheet, with the accent color overridden to Valorant's official brand red (#FF4655)
  • Plus Jakarta Sans as the sole font family (headings, body, and data — no separate monospace for numbers)
  • Team logos and agent icons are real assets (VLR.gg team logos, Riot's official agent icons via valorant-api.com), not placeholders

Project structure

vct-site/
├── public/
│   ├── data/            JSON data files (generated -- see below)
│   ├── logos/           A few team logos re-hosted locally after
│   │                    pixel-level fixes (contrast/visibility), rather
│   │                    than pointing back at the original CDN
│   └── _redirects       Cloudflare Pages SPA routing config
├── src/
│   ├── components/      DataTable, HorizontalBarChart, StackedBar,
│   │                    RoundSquares, TeamLogo, AgentIcon, FilterChips,
│   │                    MultiFilterChips, KpiCard, RankedList, TopNav,
│   │                    SearchBar, MatchHistory, PerformanceStrip
│   ├── pages/            Overview, Players, Teams, Agents, Economy,
│   │                    Records, Graphics, Tournaments, PlayerProfile,
│   │                    TeamProfile, MatchRedirect
│   ├── lib/              useData.js (fetch+cache hook), format.js
│   │                    (number/percent/color-scale helpers),
│   │                    agentIcons.json, teamLogos.json
│   ├── App.jsx           Route definitions
│   └── index.css         Tailwind + base styles
├── data_prep/
│   └── export_from_db.py  Regenerates public/data/*.json directly from
│                          the scraper's SQLite output (see below)
└── tailwind.config.js     Design tokens (colors, fonts, radius)

Regenerating the data

data_prep/export_from_db.py reads directly from the scraper's .db files (no CSV export step needed) and writes every file in public/data/:

python3 data_prep/export_from_db.py

It expects vlr_vct_2026.db and vlr_ewc_2026.db. Paths default to the constants near the top of the script but can be overridden with the VLR_DB_PATH, VLR_EWC_DB_PATH and VLR_OUT environment variables (which is how the automated workflow below points it at a restored database). It refuses to run against a missing or empty VCT database rather than writing a full set of empty JSON files over the real data. It handles:

  • Team-name canonicalization — tags (e.g. "PRX") → full names, China's sponsor-prefixed long names → their short form, EWC's sub-branded rosters (e.g. "AG.AL (All Gamers)") → their parent org, and the ULF Esports → Eternal Fire merge (same EMEA slot, held sequentially, never played each other)
  • Region/stage/phase/week tagging for the Agents page's cascading filter
  • VCT + EWC merging — every match is tagged by competition; the default (VCT-only) output is unaffected, with a parallel withEwc / statsWithEwc field added per team/player for the toggle

After running it, npm run build picks up the fresh JSON automatically.

Automated updates

.github/workflows/update-data.yml runs the whole loop above on a schedule (every 3 hours) so new results appear without anyone running the scraper by hand: scrape only what's new → regenerate public/data/ → commit → push. Pushing to main is what triggers the Cloudflare Pages rebuild, so there's no separate deploy step.

How the database persists

The scraper databases are not in this repo — ~8MB of binary that deltas badly would balloon history at that commit frequency. Each run restores them from the GitHub Actions cache, scrapes what's new, and saves them back. The cache alone is fast but not durable (entries are evicted after 7 days unused), so a db-snapshot release is kept as a durable fallback, refreshed automatically once a day and used whenever the cache comes up empty.

Nothing needs seeding by hand: if neither the cache nor the snapshot has a copy — the very first run ever, or both lost at once — the workflow just scrapes everything from scratch itself instead of failing. That run is slow (~1300 requests, roughly an hour, vs. a handful normally), but it's the only one — the very next run finds what it just saved and goes back to incremental. If you'd rather skip that one slow run, upload your existing databases as the seed before the first scheduled run fires:

gh release create db-snapshot "vlr_vct_2026.db" "vlr_ewc_2026.db" --title "Scraper DB snapshot" --notes "Seed/recovery copy for CI."

To force a full rewipe-and-rebuild on purpose (e.g. recovering from suspected data corruption, not just ordinary cache loss), run the workflow via Actions → Update VLR data → Run workflow with force_full_rebuild checked — it discards whatever was restored and starts clean.

Exit codes carry meaning end to end: a clean run publishes and goes green; an incomplete run still publishes the valid data it collected but the job is marked failed so it's visible; and a run that vlr.gg refuses outright (403/429, the expected result if the runner's datacenter IP gets blocked) aborts without publishing anything.

Local development

npm install
npm run dev        # http://localhost:5173, hot reload

Data caveats

A few known gaps in what VLR.gg publishes, surfaced directly in the UI rather than silently producing misleading numbers:

  • China-region matches don't publish multi-kill, clutch, or economy data — those columns read 0 for China players unless a player also competed internationally (in which case an "Intl-only stats" toggle uses their complete data instead).
  • ~21 China matches are missing Rating 2.0 specifically. By default those maps still count toward a player's other stats (kills, ACS, etc.), which can make a player's rating average reflect fewer maps than their other averages — a "Rated maps only" toggle makes every stat consistent by excluding those maps entirely.
  • Pistol win rate assumes exactly 2 pistol rounds per map (rounds 1 and 13 — none in overtime), and reads for China teams since no economy data exists for that region.
  • The Agents page's pick rates and map win rates are computed directly from per-map player data, not VLR's own aggregate page — verified to match VLR's own published percentages exactly (once accounting for a rounding-convention difference) everywhere a fair comparison was possible.
  • Attack/defense splits are missing on 42 of 1091 maps. All 42 sit in China events, but this is emphatically not "China has no side data" — 86% of China player-maps (2510 of 2930) have a perfectly good split, and every other region is at 100%. The gap is all-or-nothing per map (never per player), and 3 matches have a split on some maps but not others. The scraper still writes t/ct rows for the missing ones, filled with the map total on both sides; the export drops any pair failing t + ct == both, since leaving them in double-counted those players' kills under the Players page's Attack/Defend toggle. (The local match page that used to hide the side toggle for such maps, and warn when a series' side totals covered only some of its maps, has been replaced by links out to vlr.gg -- nothing surfaces that per-series caveat now.)

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages