Stride Shift
Run-walk interval timer for weight loss with level progression, beeps and voice cues, and a weekly log.
Shot charts, shooting profiles and season stats for every Euroleague player, rebuilt within minutes of every final.
Sports & fitnessHTML · PythonGitHub @baranerdogan11OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
Live app: https://elplayerlab.com/ · Live xFG API: https://euroleague-xfg.onrender.com (docs at /docs)
Player stats and animated shot charts for every Euroleague player in the 2026-27 season, updated game by game
from the league's official feeds. Pick a team, pick a player: season numbers, shooting splits, accuracy by zone
of the floor, and every field-goal attempt of the season plotted on a half court, made and missed, played back
in order.Read the full README ↗Live app: https://elplayerlab.com/ · Live xFG API: https://euroleague-xfg.onrender.com (docs at /docs)
Player stats and animated shot charts for every Euroleague player in the 2026-27 season, updated game by game from the league's official feeds. Pick a team, pick a player: season numbers, shooting splits, accuracy by zone of the floor, and every field-goal attempt of the season plotted on a half court, made and missed, played back in order.
rapm.py): a ridge regression over every stint of the season, each club's points per 100 possessions explained by the five attackers and the five defenders on the floor, the penalty picked by cross-validation over whole games; the role strip shows the total (offence plus defence) and the hover splits itlineups.py) and checked against every box-score minute count; #lineups in the top menu#standings in the top menu#game/<code>, with the score by quarter, the round's other results, the three best lines by PIR, both box scores (straight from the box table, so every line matches the player's game log and the totals equal the final score, or the build stops), both shot charts with xFG by zone and by shooter, and every five each club used with when it played; a player in the box opens his page with that game selected (#CLUB/PID/g<code>). There is no tab for them: the cover's Last night strip (the latest game day, one card per game with its best line), the fixtures ticker, a club's last result, the result in a player's game log and the lineups by-game tab all open them. Data: teams/<season>/games/<code>.json, one file per game, fetched when it is opened#ULK, #ULK/P007200, #ULK/P007200/zones (club code, player id, optional zones view)A GitHub Actions workflow (.github/workflows/update.yml) checks the games feed every 5 minutes on game evenings
(17:00 to 02:00 UTC) and runs the pipeline as soon as a game has been marked final since the last build, so a player's
page updates within about ten minutes of the final buzzer; a game still in progress is never fetched, because only
games the feed marks result count as played. It also does one full run every night after 02:30 UTC regardless, after
the last game of the day. GitHub often fires scheduled runs hours late, so several nightly slots are scheduled (02:30,
03:17, 03:47 and 04:17 UTC) and the first run after 02:30 UTC that finds no full run since then does the nightly; any
dispatch from the outside scheduler after 02:30 UTC counts too, so one dispatch at 02:30 makes it punctual. A failed
nightly is retried by the next run. The workflow can also be started by hand from the Actions tab. Each run:
tests/run_all.py, which picks up each tests/test_*.py), including the gate's own
tests, which prove the checks catch what they shouldfetch_season.py)checks.py)warehouse.py): six Parquet tables under warehouse/E2026/, the silver layer, then the
play-by-play layer (events.py): every event of every game and, per shot, whether it was assisted, an and-one,
blocked, and how many seconds into the possession it came; then the lineup layer (lineups.py, launched by events.py): who was on the
floor for every second of every game, from the starters and the substitution events; see warehouse/SCHEMA.mdwarehouse_tests.py): key uniqueness, referential integrity,
schedule completeness, per player per game reconciliation of plotted attempts and makes with the box score, and
play-by-play assists reconciled with the box score per club per game; any failure stops the run before anything
is published (one distance check is a warning only)model/score.py), see belowmodel/profile.py)build.py, the gold layer): season stats, league benchmarks, role metrics, on/off, position ranks, the defence-adjusted expectation, the compare index and the league leaderboards
and, before writing the leaders file, compares every player's season totals with the previous build (drift.py): a club's
games or a player's games played never go backwards and no total moves by more than the new games can explain; a
box-score correction is a warning, anything larger stops the run (teams/E2026/drift.json carries the verdict).
The build ends with a site smoke test (tests/test_site.py): the built page is served to headless Chrome, which
must open the cover, a club, a player page with its shot chart and game log, the standings, leaders, compare and
lineups pages at desktop and phone width without an uncaught error, a failed request or a sideways scroll, and the
service worker must compile. A page that does not draw is never published. In CI a Chrome that will not start is a
failure, not a skip ( allows the skip; on a laptop without Chrome the test is skipped).Code changes go through .github/workflows/test.yml: on every pull request and every push to main it runs Ruff over
the Python (ruff check ., rules in ruff.toml, pinned in requirements-dev.txt), the self-tests (tests/run_all.py), the warehouse assertions, the site smoke test on the committed page and a full rebuild from the
warehouse (drift check and smoke test included). A push to main deploys to Pages only after all of that passes, so
an untested commit never goes live. Pages must be set to deploy from GitHub Actions (Settings, Pages, Source).
The same workflow also runs a Node job beside the Python one (Node is tooling only: the site and the data pipeline do
not need it, and the game-night runs in update.yml do not use it):
npm run lint: ESLint over the page's script (copied out of template.html with the same line numbers), sw.js
and the Node tests; npm run typecheck: TypeScript's checker over the same script, plain JavaScript, no build step.
The loosened DOM types it assumes are in tests/lint/globals.d.ts.npm run test:e2e: Playwright (tests/e2e/). Every page at 390 and 1280 px, in English and Turkish, light and
dark, must draw without an error or a sideways scroll, with a screenshot of each in the run's artifacts; every player
in last night's games must show the same numbers on the game page, his own page, his club page and the leaders page;
the New data notice must show when a newer build lands.npm ci
npm run lint && npm run typecheck
CHROME=/path/to/chrome npm run test:e2e # or: npx playwright install chromium
The assertions write teams/E2026/status.json (games, shots, box lines, tests, failures, warnings, time) and the page footer
shows the check date, the game count and the verdict. A failed run leaves the previous good build live and uploads the status report as a
workflow artifact. Roster stints mean a player who changes club mid-season keeps his full season under his
current club.
model/xfg.py trains a gradient-boosted classifier on past seasons' warehouses (listed in model/training.json) to estimate the probability
that a shot goes in from its context: location, distance, angle, shot value, zone, quarter and clock, score
margin, home court, and from the play-by-play layer the seconds into the possession and how the possession began
(period start, opponent make, own defensive or offensive rebound, steal, opponent turnover), both known before the
release and missing for games without play-by-play. Shooter quality is a separate, leak-free
feature: each shooter's shrunk running residual (made minus xFG) over his earlier shots only, so the model never
sees the outcome it predicts; at each new season every running record keeps 60% of its weight, as in nightly scoring. Validation is time-based (the last quarter of the latest season's games held out, everything earlier trains) against
constant, zone-average and distance-bin baselines; metrics, calibration by decile and permutation importance are
written to model/model_card.json.
Trained on 2022-23 to 2025-26 (174,553 shots: 39,939, 41,331, 41,533 and 51,750; 578 shooters), held-out log
loss 0.624 against 0.645 for zone-average FG% and 0.692 for a constant, AUC 0.685, calibrated within 2.1 points in
every decile. The same model fitted on 2025-26 alone scores 0.627 on the same held-out games. Distance dominates, the seconds into
the possession are the second strongest feature (FG% runs 55% in the first six seconds, 49% mid, 42% late), the
shooter term adds a small, real gain. The 30 Sep 2026 version added the two possession features (log loss 0.630 to
0.626) and the shooting priors were recalibrated on its expectations. The 16 Oct 2026 version added the three earlier
seasons (0.626 to 0.624); the priors were recalibrated again and model/registry.json keeps all three versions.
The earlier seasons are kept in warehouse/E2022 to E2024 as shots, games and shot context only, built by
.github/workflows/retrain.yml (fetch, gate, warehouse and play-by-play steps as nightly, without photos or the
lineup layer), which also retrains, rescores and reports how far xFG% moved (model/shift.py). A first version scored 0.497 and was discarded: the feed's fast-break,
second-chance and points-off-turnover flags are only set on made shots, and the running score includes the
basket just made, so both leaked the label. They are excluded and the margin is taken before the shot.
model/score.py scores the current season nightly, carrying last season's
shooter effects forward as decayed priors. Every "expected" number on the page (the xFG row and the profile panel) uses the
context-only expectation, what a league-average shooter would do on the same shots, so the figures agree; the
shooter-aware probability appears only in the per-shot tooltip, labelled as his own.
The live API's PlayByPlay endpoint carries assists, fouls, blocks, substitutions and the scorer's clock. Assists are logged after the make they belong to, so each make is marked assisted when an assist by the same club follows it within the next four events; fouls received on the attempt mark and-ones; a block by the defence right after a miss marks it blocked. Possession start is taken as the opponent's make or last free throw, the club's own defensive or offensive rebound or steal, or the opponent's turnover, and the seconds from there to the shot are a coarse shot-clock proxy (early 0 to 6 s, mid 7 to 17 s, late 18 s and over). The page uses these for the assisted filter, the assisted share per zone, the timing table and the tooltip. Role metrics (usage, assist, turnover and rebound rates) come from the box score with both teams' totals; on/off is the team's net rating with the player on court minus off, shrunk toward zero with 1,500 minutes of prior weight and shown as descriptive, because half a season of on/off is noisy. The defence-adjusted expectation adds each defending club's shrunk residual by zone to the context-only xFG. Not in any feed, and therefore not on the page: defender distance, the real shot clock, dribbles, screens and play types.
With a context-only expectation for every shot (the same shot taken by a league-average shooter), a player's
scoring splits into shot quality (expected points per attempt on the shots he takes) and shooting skill
(what he scores on top of that, per attempt). Both are noisy over a few games, so model/profile.py shrinks
them toward the league with empirical-Bayes weights estimated from 2025-26 by variance decomposition:
skill needs 271 attempts before a player's own record outweighs the prior (between-player spread 7 points per
100 attempts), shot quality only 10 (it is a stable trait of role and position). Split-half validation on
197 players: shrinking first-half skill cuts the error in predicting the second half from 0.153 to 0.128
points per attempt, and beats predicting zero for everyone (0.135). The page shows both figures with
percentiles against last season's players and a season curve of the skill estimate with its ±1 sd band.
A first version measured skill against the shooter-aware xFG and found zero between-player variance, which is what should happen when the expectation already contains the shooter; quality and skill must be measured against the context-only expectation.
The site is served by GitHub Pages and takes a custom domain with one file and two DNS records. Order matters:
the CNAME file makes the GitHub address redirect to the domain, so it goes in only after the DNS is live.
185.199.108.153, 185.199.109.153,
185.199.110.153, 185.199.111.153) and add a CNAME record for www to baranerdogan11.github.io.dig <domain> returns those addresses, add a file named CNAME at the repository root containing the
bare domain and push. build.py reads it and switches the share stubs' Open Graph URLs to the domain.Nothing else in the app depends on the address: the page fetches its data by relative path and the share links are built from wherever the site is served.
monitor.html is the system's status page,
fed by teams/E2026/monitor.json which model/monitor.py writes at the end of every nightly run (and an append-only
warehouse/E2026/run_history.jsonl). It shows: pipeline result and run history; data freshness against the
schedule (games played per round, days since the last game); the xFG model's log loss and Brier per round on
this season's shots against a constant baseline and the training season, plus predicted versus actual make rate
and season-to-date calibration by decile; shooter-effect coverage; the serving API's reachability, latency and served model hash checked against the registry.
Orange crossing grey on the calibration chart is the retrain signal.
The page reports usage and errors to GoatCounter when analytics.txt in the
repository root holds the site's endpoint (https://<site>.goatcounter.com/count); without the file nothing is
loaded. Every hash route counts as a page view (/MAD/P015244, /standings, / for the opening page), and an
uncaught exception or promise rejection in the page is sent as an event named error/<message>, so a phone that
never reaches the test matrix still reports what broke. GoatCounter sets no cookies and stores no personal data;
visits from localhost are not counted.
service/app.py serves the deployed model with FastAPI:
POST /predict: up to 500 shots per call, each validated (coordinates inside the court, 2 or 3 points,
clock within a period); returns xFG, the context-only xFG for a league-average shooter, expected points and
distance per shot, plus the model version in the body and in an x-model-version header. An optional league
player code applies the shooter effect when the player is known.GET /health: liveness, model version and file hash. GET /model: model card and registry entry.LOG_FILE to also append to a file.model/registry.json records each trained model with its SHA-256, the SHA-256 of the training shots, metrics,
features and stage (production, candidate, archived); python model/register.py --activate promotes the
current model. The service reports which registry entry it is running.
tests/test_service.py holds 14 contract tests (schema validation, monotonic sanity checks, version headers,
shooter effect direction on average) and runs in both workflows. .github/workflows/service.yml runs them,
builds the Docker image, publishes it to GitHub Container Registry as
ghcr.io/baranerdogan11/euroleague-xfg:latest (and one tag per commit), then starts the published image and
calls /predict as a smoke test. render.yaml deploys the same image to Render's free tier with a health check.
The service is deployed at https://euroleague-xfg.onrender.com (free tier: the first call after idle takes about 30 seconds); interactive docs at https://euroleague-xfg.onrender.com/docs.
curl -X POST https://euroleague-xfg.onrender.com/predict -H 'content-type: application/json' -d '{"shots":[{"x":0,"y":700,"pts":3}]}'
docker run -p 8000:8000 ghcr.io/baranerdogan11/euroleague-xfg:latest
curl -X POST localhost:8000/predict -H 'content-type: application/json' \
-d '{"shots":[{"x":0,"y":700,"pts":3},{"x":10,"y":50,"pts":2,"minute":38,"clock":"00:20","home":false,"margin":-3,"player":"002100"}]}'
| Layer | Where | Produced by | Tests |
|---|---|---|---|
| Bronze | cache/E2026/ (raw feed JSON, not committed) | fetch_season.py | checks.py |
| Silver | warehouse/E2026/*.parquet | warehouse.py | warehouse_tests.py (30 assertions), tests/test_warehouse.py (real-game fixture) |
| Gold | teams/E2026/*.json, index.html | build.py | rendered site |
python3.11 -m venv .venv && . .venv/bin/activate # CI runs 3.11 (.python-version); newer Pythons cannot build the pinned wheels
pip install -r requirements.txt
python fetch_season.py E2026 # clubs, crests, rosters, photos, then shots and box scores for new games
python checks.py E2026 && python warehouse.py E2026 && python warehouse_tests.py E2026
python build.py E2026 # writes index.html and teams/E2026/*.json from the warehouse
git add -A && git commit -m "Update after round" && git push # test.yml checks the commit, then deploys it to Pages
fetch_season.py caches every game, roster and image under cache/, so a rerun only downloads what is new. Games
within 72 hours of tip-off are fetched again at most every six hours (cache/<season>/fetched_at.json keeps the times),
so box-score and play-by-play corrections the league makes after the buzzer reach the site; the drift check reports
them as warnings. If a refresh fails, the cached copy is kept.
index.html: the page (loads a club's JSON when it is selected); monitor.html: the status pageteams/E2026/roster.json: the search index, compare peers and leaderboard names, fetched after first paintp/<pid>.html: one static page per player (name, card, bio, career) carrying his Open Graph tags, canonical to itself, forwarding to the app by script; cards/<pid>.jpg: his 1200x630 share card, redrawn only when name, number, club, position or photo change (cards/manifest.json holds the input hashes); og/home.png: the home cardsitemap.xml, robots.txt, 404.html: search engines get every player page and none of the code or data folders; a mistyped address lands on a page with the club grid and the player searchmanifest.webmanifest, icons/, sw.js: installable, with an offline shell; the page and JSON go network-first, photos, crests and fonts cache-first for thirty days, the worker stamped with the build so old caches dropfonts/: Barlow and Barlow Condensed served from this origin (latin woff2 for the page, TTF for the cards; OFL licence alongside)feed.json, feed.xml: one entry per rebuild that added games or shots; cal/<CLUB>.ics: every fixture of the club with results as they land, linked from the fixtures tickerembed.html?club=<CODE>&pid=<PID>: the shot chart and zone table alone, for an iframe; the Embed button on a player page copies the snippetlogos/<CODE>.webp: the crests the site serves, 128 px, made from the feed's PNGsindex.html shows a cover when the URL carries no club: the wordmark, one paragraph on what the app does,
the player search, live counts of clubs, players, games and shots charted, a grid of all twenty crests, and a
button that enters at the first club on the list, or continues at the last player viewed in that browser
(localStorage pl-last). A fresh visit to the site always opens on the cover, whatever the address carried,
because phones restore the last player they showed; once the reader has entered the app in that tab
(sessionStorage pl-in, which the p/<pid>.html share stubs set before forwarding), reloads and
back/forward keep their place. The header wordmark returns to the cover, so the app stays one hash-routed
page with no second document to keep in step.
The page is in English or Turkish, switched with the two flags at the top right (header and opening page) and
remembered in the browser (localStorage pl-lang; a first visit follows the browser language). Every static
text, title, label and placeholder keeps its English as a key (data-t, scanned at boot), every dynamic string
goes through t('English text', {placeholders}), and lang/tr.json maps the English to Turkish; build.py
inlines it into the page so switching is instant and re-renders the current page without a reload. Dates and
thousands separators follow the language. Club and player names, stat abbreviations shared by Turkish
broadcasters (PIR, FG%, xFG%) and the status, share and embed pages stay in English.
Four boards, points, rebounds and assists a game and three-point percentage, are precomputed in build.py
(leaderboards()) and inlined in index.json as leaders, about 600 bytes. Rows carry only a player id plus
the raw totals; the page joins names, clubs and photos from roster, whose entries also carry each player's
rank on every board so the viewed player can be pinned under the top ten.
Qualification is pro-rated off the player's own club's played games rather than fixed at a games count, the
way the NBA pro-rates its 70%-of-games rule in season, so the qualifying pool stays flat as the season grows
instead of tripling: a player needs appearances in 70% of his club's games and 10 minutes a game. The
three-point board is gated on attempts alone, max(15, 2 x rounds), and ranked on the raw percentage. The
floor of 15 is the same attempt minimum the rest of the file already requires before it will print a 3P%.
Before anyone can reach that gate, the board drops to the highest attempt count that still fields ten shooters
(never under 5) and says so on the page; the relaxed gate lifts itself back to 15 within a few rounds.
Empirical-Bayes shrinkage was measured on 2025-26 and rejected: at the season-end gate it compresses 23.9
points of spread to 4.8, so every row would round to the same number.
p/<pid>.html: one share stub per player with his Open Graph card (name, club, photo), forwarding to his page; written by build.py, handed out by the page's Copy link buttonteams/E2026/index.json, teams/E2026/<CLUB>.json: compact per-club data (stats, game log, shots)teams/E2026/games/<code>.json: one played game for its game page (quarters, both box scores and totals, shots with xFG, top three lines, the fives of both clubs); index.json carries last_night, the latest game day, for the cover stripphotos/<player>.webp, logos/<CLUB>.png: media-day photos and crestswiki_careers.py): a page is accepted only when its title carries the surname and either the first name with an agreeing birth year or the exact birth date from the roster feed, which is what identifies Sasha Vezenkov as Aleksandar Vezenkov and rejects a twin brother. Pages are fetched once per season into cache/<season>/wiki_<player>.json and stored in the careers_wiki table. Players without an English page, about one in twelve, show their Euroleague seasons alone, from the league's people feed (careers table). Either way the list ends on the current club for this season.warehouse/E2026/*.parquet: the queryable season warehouse (schema in warehouse/SCHEMA.md)fetch_season.py, checks.py, warehouse.py, warehouse_tests.py, build.py, template.html: the pipeline and page sourceThe page fetches its data files, so serve the folder over HTTP to run it locally
(python -m http.server 8000, then open http://localhost:8000/); opening index.html directly from disk will not load data.
Rosters, crests, photos, box scores and shot coordinates come from the Euroleague official API and live feeds. Coordinates are in centimetres relative to the basket; the court is drawn to FIBA dimensions.
PL_SMOKE_REQUIRED=0model/monitor.py), commits everything and deploys that commit to GitHub Pages
(.github/workflows/pages.yml)