BikeControl (SwiftControl)
使用 Zwift Click、Zwift Play、Shimano Di2 或任何藍牙控制器,搭配 MyWhoosh、Rouvy、Zwift 等。iOS、Android、Windows、macOS 均可免費試用。
AI 翻译
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Cloudflare Workers backend for EyeWall Analytics — NHL, PWHL, AHL, and ECHL live game polling, data API for the frontend, push notifications, and AI-generated content.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
Cloudflare Worker backend for EyeWall Analytics. Handles NHL/PWHL/AHL/ECHL polling and data serving, push notifications, and AI-generated content.
Wrangler bundles all modules on deploy. The scheduled trigger (* * * * *) runs poll(), pollPWHL(), pollAHL(), pollECHL(), refreshPPUnits(), and refreshSeasonsCache() every 60 seconds during the season to keep NHL/PWHL/AHL/ECHL data and the resolved season fresh in KV.查看完整 README ↗Cloudflare Worker backend for EyeWall Analytics. Handles NHL/PWHL/AHL/ECHL polling and data serving, push notifications, and AI-generated content.
src/
├── worker.js # Thin router + scheduled entry point
├── nhl.js # NHL poll loop and push notifications; the catch-all for every route worker.js doesn't answer itself or hand to a league prefix, dispatching /nhl/edge, /nhl/goal-replay and /live-activity to their modules
├── pwhl.js # /pwhl/* endpoints and pollPWHL (playoff-odds/power-rankings live in leagueOddsRankings.js)
├── hockeytech.js # Shared AHL/ECHL implementation: poll loop, push notifications, all /ahl/* and /echl/* endpoints (2026-09)
├── hockeytechGameDetail.js # goal_on_ice/penalty_shots/game_win_probs for the PWHL/AHL/ECHL game-box and schedule routes (2026-10)
├── ahl.js # AHL config for hockeytech.js: team codes, news sources, HockeyTech client (added 2026-08)
├── echl.js # ECHL config for hockeytech.js (added 2026-08)
├── liveActivity.js # iOS Live Activities for all four leagues: push-to-start, updates, the HockeyTech ContentState, /live-activity/* (2026-10)
├── ops.js # Owner ops alerts: /ops/notify, /ops/subscribe, per-league cron health + self-alert (2026-10)
├── dispatch.js # Starts the pipeline's daily GitHub workflows and social posts on time (workflow_dispatch, 2026-10)
├── milestones.js # Which `milestones` rows belong to nhl/pwhl/ahl/echl, for /milestones and /milestones/latest (2026-10)
├── callupWatch.js # /nhl/callup-watch ranking (pure; nhl.js gathers inputs) (2026-10)
├── edge.js # /nhl/edge/{skater,goalie,team,leaders} — NHL EDGE tracking stats (2026-10)
├── goalReplay.js # /nhl/goal-replay/:gameId/:eventId — one goal's player/puck tracking (2026-09)
├── hockeytechBracket.js # Calder/Kelly Cup bracket builders + PLAYOFF_FORMATS (C11, 2026-10)
├── hockeytechGame.js # Per-game helpers for PWHL/AHL/ECHL: who dressed, by name; isExtraAttackerPull() (2026-10)
├── hockeytechPrediction.js # /pwhl,/ahl,/echl prediction body: Elo + AI prompt (2026-09)
├── hockeytechSeasonRows.js # Combines traded AHL/ECHL players' season rows (2026-10)
├── injuryImpact.js / playoffOdds.js / probableStarters.js / projectedLines.js / scorecard.js / scratches.js
│ # Pure shape helpers for the NHL routes of the same names (2026-09)
├── leagueOddsRankings.js # /{pwhl,ahl,echl}/playoff-odds and /power-rankings (C10/C12, 2026-10)
├── penaltyText.js # Readable NHL penalty text for pushes and the Live Activity (2026-10)
├── trades.js # /trades/tree breadth-first trade tree (2026-09)
├── transactions.js # /transactions trade-half pairing (2026-09)
├── contracts.js # /contracts, /contracts/player/:id, /contracts/players, /contracts/summary, /config/cap builders (2026-10)
├── seasons.js # Live NHL/PWHL/AHL/ECHL season resolution (NHL/PWHL added 2026-07, AHL/ECHL 2026-08), cached in KV
└── shared.js # KV helpers, response utilities, shared constants
Wrangler bundles all modules on deploy. The scheduled trigger (* * * * *) runs poll(), pollPWHL(), pollAHL(), pollECHL(), refreshPPUnits(), refreshSeasonsCache() and maybeDispatchWorkflows() (see Pipeline scheduling) every 60 seconds during the season to keep NHL/PWHL/AHL/ECHL data and the resolved season fresh in KV. Each league's poll is tracked on its own (ops.js's trackCron(), writing health:cron:<league>), so one league throwing can't skip the others or the alert-log flush; after the polls, any league in season with no successful tick for 15 minutes raises a worker-cron-<league> ops alert (see Ops alerts).
Bindings: CACHE (KV), AI_ROUTE_LIMITER (Rate Limit — guards the unauthenticated AI-calling routes from public-cost abuse), PUBLIC_WRITE_LIMITER (Rate Limit — guards the unauthenticated write routes: /push/subscribe, /push/unsubscribe, /live-activity/register, /live-activity/start-token). AI generation (all narrative/scouting endpoints) went through a [ai] Workers AI binding until 2026-08; it's now a plain fetch() to OpenRouter (OPENROUTER_API_KEY secret) instead — see Model provider below.
Every GET JSON response carries Cache-Control: public, max-age=N and a weak ETag (a SHA-1 of the body). A request whose If-None-Match matches gets a bodiless 304, so the browser re-downloads a response only when it changed. shared.js's withHttpCache() wraps handleRequest and sets these centrally; routes only say how long (json(val, { maxAge }), which cachedJson() fills in from its KV TTL).
| Responses | Cache-Control |
|---|---|
A KV-cached route (cachedJson(), or a hand-rolled kvGet/kvPut passing maxAge) | max-age = the route's KV TTL (300 s, 1800 s, 3600 s; data-dependent TTLs such as /{ahl,echl}/shots' 300/3600 follow the data), capped at 3600 s (MAX_MAX_AGE): a KV hit is served with the full TTL even when the entry expires a minute later, so a longer max-age could leave a device a day behind a /cache/bust or a nightly run |
Live-game routes (LIVE_PATHS): /nhl/today, /{pwhl,ahl,echl}/today, /{pwhl,ahl,echl}/live/:id, /{ahl,echl}/schedule (live scores laid over it), /cache/pbp:*, /cache/boxscore:*, /cache/schedule:*; plus /schedule for the current/next season (re-stamped with live states every poll) | max-age=5 (LIVE_MAX_AGE), whatever the KV TTL: the app polls a live game every 10 s, and half that interval leaves no window for the browser to answer a poll from cache |
A GET with no KV cache of its own (/health, /cache/standings, /news/latest, a build that is deliberately not cached such as an empty /scorecard) | max-age=30 (DEFAULT_MAX_AGE) |
An empty answer whose cache is being filled in the background (/news, /pwhl/news, /{ahl,echl}/news, /news/latest, a failed /schedule fetch) | max-age=0 |
/alerts/recent | its own public, max-age=30, unchanged |
POST/DELETE, any 4xx/5xx, a request with secret= or force=, or with an Authorization, X-Poll-Secret or x-ingest-secret header (/admin/health, any owner call) | no-store, no ETag |
The CORS preflight (OPTIONS) is untouched. Cloudflare's edge does not cache Worker responses, so these headers only reach the browser (and any proxy in between).
Added 2026-07, replacing what used to be a yearly manual flip of NHL_SEASON/PWHL_CURRENT_SEASON across this repo, the frontend, and the pipeline. seasons.js is the single source of truth for "what season is it right now" — everything else (this repo's own nhl.js/pwhl.js, the frontend's teamConfig.js/pwhlConfig.js, the pipeline's season_lookup.py) reads from it rather than resolving independently.
resolveNHLSeason(env) — calls api-web.nhle.com/v1/standings/now, rejects the candidate if gamesPlayed is 0, falls back to a hardcoded seed otherwise. This alone is too late a signal for schedule/roster-facing UI — gamesPlayed only ever flips once real games have actually been played, but the app needs to show the new season's schedule/roster once training camp is genuinely imminent, weeks before that. nextSeasonHasImminentSchedule(seasonId) covers that gap: after computing the gamesPlayed-based candidate (or the fallback, if rejected), it separately checks whether the season after that already has a published schedule with its first game (preseason counts) within SCHEDULE_LOOKAHEAD_DAYS (21) — if so, resolution flips forward to it. Verified live 2026-09-11: with no override set, this correctly auto-resolves to 20262027 (CAR's real preseason opener 9 days out) even though standings/now still only has real 20252026 data (no 20262027 games played yet) — the actual real-world boundary case this was built for, not a hypothetical.
Anchored on the standings candidate, not the seed (2026-10, audit Worker F3). When standings/now already names the new season with 0 games played, the look-ahead checks that season's schedule. If it is imminent, the candidate becomes current. If not, the season before it is current, cached for 1 hour (source: 'live-prior'). The look-ahead used to run from FALLBACK_NHL_SEASON in that case, so in Sept 2027 (standings 20272028, seed 20252026) it would have looked at 20262027 and never considered 20272028 until regular-season games were played. nextSeasonHasImminentSchedule() also rejects a season whose first game is more than 60 days in the past (SCHEDULE_LOOKBACK_DAYS): a season already played is not "next". The 60 days still cover a preseason that started weeks before standings shows any games.
resolvePWHLSeason(env) — calls HockeyTech's bootstrap view (feed=statviewfeed&view=bootstrap, not feed=modulekit — the latter returns a 200 OK with no real payload and silently masqueraded as working for a while before being caught), rejects current_season_id if it's hide_in_standings: true, and — important — prefers the most recent regular season over the most recent season of any type. This isn't optional: almost every /pwhl/* endpoint filters season_type=eq.regular downstream in Supabase, so resolving to a playoffs-type season_id broke every PWHL view in production for a stretch (caught via Cypress, not by this logic itself) before this preference was added.
The PWHL season after the current one — pickPWHLSeasonContext() reads two more answers out of the same bootstrap list: next, the earliest non-hidden regular season that starts after the current one (the one the 14-day lookahead is still holding back — on 2026-10-01, season 11, 2026-27, first game 2026-12-05, with season 8 current), and the preseason that leads into each regular season (its own season_id, usually hidden: 10 = 2026-27 Pre-Season, games Nov 22–30). next is relative to the current season, not the clock, so it always agrees with whatever resolvePWHLSeason() returned (cached or overridden); it's null once season 11 is current until HockeyTech lists 2027-28. GET /config/seasons's pwhl entry carries both as next: {seasonId, seasonType, startYear, startDate, preseason} and preseason (the current season's), each null when there isn't one or the bootstrap is unavailable. The pipeline ingests their schedules nightly; the app offers the next season's schedule once it has games. getPWHLScheduleSeasonIds() turns the same answer into the season_ids /pwhl/today reads.
Both are cached in KV (config:season:nhl, config:season:pwhl, 6hr TTL) and exposed at GET /config/seasons. The scheduled() handler calls both on every tick, but since they check the cache first, this is a cheap no-op except right after the TTL lapses — it does not hit the NHL/HockeyTech APIs every 60 seconds.
Manual override, for if live resolution ever misjudges the real season boundary (this has happened once already, and the true Sept/Oct transition boundary has never been directly observed):
wrangler kv key put --binding=CACHE "config:season:nhl:override" '"20262027"' --remote
wrangler kv key put --binding=CACHE "config:season:pwhl:override" '{"seasonId":9,"seasonType":"regular","startYear":2026}' --remote
Note the --remote flag — wrangler kv key delete/put/get without it operates on the local/preview namespace, not the one this Worker actually reads in production. This cost real debugging time once already; don't repeat it.
Delete the override key(s) once live resolution is confirmed correct again — they take priority over everything else, including the cache.
Known gap: nhl.js's 32 TEAM_CONFIGS entries and pwhl.js's per-endpoint season handling were updated to read from seasons.js — but this was a deliberate, careful pass through every call site (not a blind find-and-replace), since getTeamConfig() had to become async and every caller needed updating. If a new endpoint or team-config consumer gets added later, make sure it reads the live value rather than reintroducing a hardcoded season.
Also found and fixed in nhl.js while doing this pass: a second, separate hardcoded MP_SEASON constant driving the MoneyPuck CSV fetch URL — the same decoupled-season bug shape as the one already known about in the pipeline's moneypuck.py, just duplicated a second time on the Worker side. Both are now derived from the live-resolved season instead of two independent hardcoded copies.
AHL and ECHL (added 2026-08) follow the same live-resolution pattern via resolveAHLSeason(env)/resolveECHLSeason(env) (seasons.js), but against a different vendor call shape than PWHL's: feed=modulekit&view=seasons (not statviewfeed&view=bootstrap), a different HockeyTech client per league (client_code=ahl, key=ccb91f29d6744675, site_id=3 vs. client_code=echl, key=2c2b89ea7345cae8, site_id=0, league_id=1), and no hide_in_standings flag to filter on. Instead, "is this candidate season real" is decided by career === '1' (skips All-Star Challenge entries) and start_date not being in the future (skips an already-announced-but-not-started upcoming season) — mirrors eyewall-pipeline's own ahl_stats.py/echl_stats.py resolve_current_season() logic exactly, including the reasoning behind it: a naive "max season_id with career=1" picked a season with zero games in testing (AHL's 2026-27, which starts October) instead of the actually-current one.
ECHL's HockeyTech key is not recoverable the way AHL's/PWHL's are. echl.com was rebuilt on Laravel/Livewire and renders stats server-side, so the usual "open the network tab" recovery path doesn't work there. The key currently in seasons.js (ECHL_HT_KEY) was recovered from sportsdataverse-py's league registry (hockeytech/_leagues.py) and independently re-verified live against the real feed. If it ever stops working, re-check that registry first — a network-tab hunt on echl.com will fail for the same reason it did during the original investigation.
Cached in KV: config:season:ahl / config:season:echl (6hr TTL, same TTL_SECONDS constant as NHL/PWHL), with fallback seeds {seasonId: 90, seasonType: 'regular'} / {seasonId: 73, seasonType: 'regular'} for a total cold start. Manual override keys, same escape-hatch pattern as NHL/PWHL above:
wrangler kv key put --binding=CACHE "config:season:ahl:override" '{"seasonId":90,"seasonType":"regular"}' --remote
wrangler kv key put --binding=CACHE "config:season:echl:override" '{"seasonId":73,"seasonType":"regular"}' --remote
getAllAHLSeasonTypes()/getAllAHLSeasons() and their ECHL equivalents mirror PWHL's getAllPWHLSeasonTypes()/getAllPWHLSeasons() — an id → season_type map for arbitrary/historical seasons, and full per-season metadata, respectively. The AHL/ECHL lists ({seasonId, seasonName, seasonType, startYear, startDate, endDate}) back both the season-comparison picker and GET /config/seasons/ahl-seasons / /echl-seasons, which eyewall-pipeline reads for any season's type and its date window instead of calling HockeyTech's seasons feed itself. Real bug found and fixed while building ECHL's own Compare-Seasons feature (2026-08-30): worker.js's /config/seasons/comparison never actually built an ahl key, despite a frontend comment (AHLTeamView.jsx) claiming it had since AHL/PWHL parity Phase 4 — the route only ever built nhl/pwhl keys, so AHL's own "Compare Seasons" picker had been silently showing its "no seasons available" empty state in production since that phase shipped. Fixed by adding getAllAHLSeasons()/getAllECHLSeasons() (mirroring the already-correct getAllPWHLSeasons()) and building real ahl/echl entries alongside nhl/pwhl in that route — fixing AHL's picker live and giving ECHL's brand-new one working season labels from day one, instead of reproducing the same gap a third time. See Config Endpoints below.
Track B Phase B2 (2026-08) — serving layer for eyewall-pipeline's locale-aware AI narrative content (Phase B0 schema, Phase B1 generation, both already shipped). game_summaries, player_scouting, and player_narratives (Supabase) each carry a locale column (en/fr) as of Phase B0, widening their upsert conflict keys — a row can now exist in both languages for the same game/player/season.
Four routes accept a ?locale=en/?locale=fr query param, defaulting to en for any missing or unrecognized value (same defensive posture as this file's existing team/season param handling) — /game-summary, /player-scouting, /player-results-vs-process (nhl.js) and /trivia/today (worker.js). Each filters its Supabase query on locale=eq.<value> and appends :<locale> to its KV cache key, so English and French responses cache independently rather than one clobbering the other.
Pre-game predictions (2026-09) — /game-predictions also filters on locale (game_predictions got its locale column in eyewall-pipeline's docs/sql/session_locale_predictions.sql; its KV key gets a :fr suffix for French only). The on-demand AI prediction routes — /prediction/analyze (both the in-season branch and buildPreseasonFallback()), /pwhl/prediction, /ahl/prediction and /echl/prediction — take ?locale=fr too: shared.js's localizePrompt() appends FRENCH_INSTRUCTION (a copy of the pipeline's STICKS_SYSTEM_PROMPT_FR_ADDENDUM — keep the two in step) and localeKeySuffix() gives French its own :fr KV key. English keys are unchanged, so existing caches and the frontend's /cache/prediction:{gameId}:{team} lookups keep working. /ahl/summary/narrative and /echl/summary/narrative (2026-10) take ?locale=fr the same way. The other on-demand AI routes (/summary/narrative, /pwhl/scout, the head-to-head narratives, /draft/analyze) are still English-only.
Known gap: trivia_questions's hard tier is hand-curated with no admin UI (see trivia_questions.py's docstring in eyewall-pipeline) and every existing hard row defaults to locale='en' — there is no French hard-tier content yet. A locale=fr request's hard tier will legitimately come back empty (same "not published yet" empty state this route already falls back to for other gaps) until French hard rows are added by hand.
The frontend (eyewallanalytics) passes locale on every route above (the app's i18n language), so Track B Phase B2 is complete.
Added 2026-08 as the 3rd and 4th leagues (ahl.js/echl.js), following NHL/PWHL's exact patterns: same Supabase-REST-direct query shape, same KV caching shape, same push-notification/live-tracking structure. Both leagues sit on the same HockeyTech/LeagueStat vendor as PWHL — same endpoint shapes, just a different client per league (see Live Season Resolution above for the exact config values and the ECHL-key-recovery caveat).
One implementation, two configs (2026-09). The two leagues were built as ~1,750-line near-copies; they now share hockeytech.js's createHockeyTechLeague(), and ahl.js/echl.js hold only each league's config — team codes, news sources, headshot size, and HockeyTech client. The merge was pinned by hockeytech-leagues.characterization.test.js (see Testing): every route's requests, KV writes and responses stayed identical, except that ECHL gained /echl/player-game-log.
Both leagues share a real, permanent data-shape ceiling below PWHL — confirmed live against production data across multiple real games for each league (not assumed from one sample), documented in hockeytech.js's module docstring:
modulekit/gameshifts view returns an empty {home: [], visitor: []} shape for every AHL/ECHL game tested, confirmed against a PWHL control game that does return populated shifts through the identical request shape. Consequence: no TOI, no on-ice Corsi/Fenwick, no shift-based WAR/RAPM — a harder limit than PWHL's own goal-scoped on-ice data, which is at least a coarse fallback.hit/faceoff/blocked_shot event types in the PBP. Only goal, shot, penalty, goalie_change, penaltyshot plus shootout (confirmed real for AHL in game 1029078, 2026-10-02, and for ECHL — see echl_penalty_shots.py; its details carry shooterTeam and no period/time) exist. The box-score schema even carries hits/faceoffAttempts/faceoffWins/faceoffWinPercentage fields per player and per team, but they're hardcoded "0" in every real game checked for both leagues — confirming it's not charted anywhere, not merely absent from the PBP. /ahl/summary and /echl/summary strip these fields from HockeyTech's raw response rather than passing along a fabricated "0 hits" line the frontend would have to know not to trust./ahl/prediction and /echl/prediction started as ports of /pwhl/prediction's heuristic without the Corsi term; since 2026-09 their win % is Elo instead (see the /ahl/prediction row below).ahl_game_log.ended_in/echl_game_log.ended_in ('OT' | 'SO' | null) say how a final ended, so standings L10/streaks and /prediction streaks split OT/SO losses ('OT', counted in l10OTL) from regulation losses ('L') — since 2026-10; before that every non-win counted as 'L'. ahl_team_seasons/echl_team_seasons carry ot_losses and shootout_losses as two separate columns rather than one non-reg-losses-derived field the way PWHL combines them.ahl_player_seasons/echl_player_seasons have no shot_pct/// columns — confirmed absent from both leagues' HockeyTech feeds entirely, not just occasionally null.AHL was built first, ECHL as a fast-follow, per the original investigation's recommendation (eyewall-poller#69 through #74) — both shipped the same 6-phase progression:
/ahl/standings, /schedule, /roster, /players, /league-players, /shots, /team-season-summary (#69). ECHL's own foundation pass (#75) shipped the identical 7 routes./player/landing, /player/career, /player-shots (AHL #70, ECHL #76)./lastgame, /summary, /preview, /game-box, /prediction, plus AHL's /player-game-log (AHL #71, ECHL #77). /player-game-log shipped for AHL only at first; ECHL got it in 2026-09 when the two leagues merged into hockeytech.js. It's the data for the player popup's Compare-tab trend chart (same role as /pwhl/player-game-log)./team-seasons/compare, /compare-teams, /head-to-head, /head-to-head/narrative, plus /config/seasons/comparison's league entry (AHL #72, ECHL #79 — see Live Season Resolution above for the real AHL bug this phase found and fixed)./news, /news/ingest, /news/bust, plus a sport=ahl/sport=echl entry in (AHL , ECHL ).News sources differ in count, not structure. AHL has 3 (theahl.com/feed — the official league site, no PWHL equivalent since pwhl.com has no news RSS at all; thehockeywriters.com/category/ahl/feed/; OurSportsCentral's AHL feed, league id 17). ECHL has only 2 — confirmed live that echl.com has no discoverable RSS feed at all (/feed and /rss both 404, the same Laravel/Livewire rebuild that keeps its HockeyTech key off the network tab too) — just thehockeywriters.com/category/echl/feed/ and OurSportsCentral's ECHL feed, league id 18, not 17 (searched for it live rather than assumed sequential-by-launch-date IDs on that site). All 5 sources across both leagues are scoped to their own league by construction, so unlike PWHL_NEWS_SOURCES, none need keyword filtering.
Team ID → abbreviation maps (AHL_TEAM_CODES in ahl.js, 32 current teams; ECHL_TEAM_CODES in echl.js, 30 current teams plus two 2025-26 ids, ECHL_HISTORICAL_TEAM_IDS: Iowa, Utah) are hardcoded snapshots, same convention as PWHL_TEAM_CODES — no ahl_teams/echl_teams table exists; team display metadata is a frontend concern. AHL_TEAM_CODES also carries one historical entry (317: 'BRI') for the Bridgeport Islanders, who relocated to become the Hamilton Hammers (457) for 2026-27 — needed so historical-season queries against the old franchise still resolve correctly. If a future realignment or expansion changes either league's team IDs, add the new id here — the same "multiple independent per-league team-ID maps across repos" gotcha PWHL's own October Season Prep note below already documents applies to AHL/ECHL too.
Real bugs found and fixed along the way (beyond the AHL comparison-seasons config gap covered under Live Season Resolution above):
expiration_ttl is 60 seconds, not 30. /ahl/live/:gameId and /echl/live/:gameId both use a 60s TTL for a non-final game (not PWHL's original 30s) — confirmed live against a real completed game that a 30s value throws KV PUT failed: 400 Invalid expiration_ttl. The same bug existed in /pwhl/live/:gameId and was fixed there in the same pass, since it had apparently never been exercised against a real non-final game before.shared.js's safeId(sourceId, link) truncated its hash to a fixed-length prefix of the article URL, which collides whenever two articles' URLs share a long common prefix — confirmed live while building AHL's news feed: theahl.com's own RSS feed republishes NHL.com's "32 in 32" prospect series, and every article in that series produced the identical id and collapsed to one row after dedup. Fixed by hashing the full link string instead of a truncated prefix. This is shared code (used by every league's news parser, not just AHL's) — production PWHL/NHL news had zero actual duplicate ids at the time of the check, but it was the same latent risk for any future source whose URLs share long prefixes.shared.js's encryptPushPayload() (RFC 8291 Web Push) had 3 string literals with a NUL byte where a mandated trailing space belonged ('WebPush: info\0', 'Content-Encoding: aes128gcm\0', 'Content-Encoding: nonce\0'), broken since 2026-06-22 and found while building ECHL's game/prediction routes (eyewall-poller#78, merged). This function backs every push notification across every league — broadcastAHL/broadcastECHL included — so every push before this fix silently degraded to a generic fallback notification rather than showing real content, because the browser fails to decrypt the payload before the service worker ever sees it. Fixed via a byte-level replace and proven with a from-scratch RFC 8291 round-trip test (encrypt → independently decrypt with the receiver's own private key → confirm the plaintext matches), re-run against the pre-fix code to confirm it throws an auth-tag mismatch./ahl/cache/bust or /echl/cache/bust route exists, unlike PWHL's POST /pwhl/cache/bust, which clears one team's whole set of caches in a single call. The generic POST /cache/bust?key= (2026-09) covers the same ground one key at a time, so a stale AHL/ECHL entry no longer needs wrangler kv key delete by hand — what's still missing is only the convenience of clearing a team's several keys at once.Test coverage. src/__tests__/hockeytech-leagues.characterization.test.js (added 2026-09) covers every AHL and ECHL route, the live-game poll, and the news fetch for both leagues with snapshot ("golden master") tests — see Testing below. src/__tests__/ahl-routes.test.js still holds the original hand-written assertions for AHL's 7 foundation routes.
engines requires it; CI and deploy use 22)npm install)npm install
wrangler login
npm run dev
Runs the Worker locally via wrangler dev. Binds to your real KV namespace by default — use a preview namespace for isolation if needed.
Added 2026-07 — this repo had no test infrastructure before then. Session 47 added an HTTP-route-level test harness and covered a first slice of routes; Session 48 filled in the rest of the previously-untested HTTP surface (110 tests total as of Session 48). As of 2026-10-24: 2,029 tests in 55 files (npx vitest run).
npm install
npm run test # or: npx vitest run
vitest.config.js runs under plain Node (environment: 'node'), not @cloudflare/vitest-pool-workers — deliberately. Route handlers only touch Workers-specific APIs via env.CACHE/env.AI_ROUTE_LIMITER/env.PUBLIC_WRITE_LIMITER, all of which mock cleanly without a real Workers runtime (see src/__tests__/route-harness.js). AI generation is a plain fetch() call (OpenRouter, since 2026-08) rather than a Workers binding, so it's mocked the same way as every other external call — mockFetchWithAI()/aiCalls()/aiPrompt() in the same harness file.
Test files:
src/__tests__/seasons.test.js — seasons.js's own resolution logic. Covers: the manual override, KV cache hits, the "reject a zero-games-played candidate" fallback, the "reject a hidden current_season_id" fallback, and — the two tests that actually matter most — a regression test asserting feed=statviewfeed (not feed=modulekit) gets called, and a fixture built from the real 2026-07-05 production bootstrap payload confirming resolution picks season 8 (regular) over season 9 (playoffs) even though 9 is more recent by date. Both of those fixtures exist because they're exactly the two real bugs that shipped to production before being caught.src/__tests__/worker-routes.test.js — the dispatcher (/config/seasons, /config/seasons/pwhl-types, /config/seasons/ahl-seasons / /echl-seasons, /pwhl/* vs. everything-else routing) and /cache/bust (including the protected-key deny list and the X-Poll-Secret header).src/__tests__/nhl-routes.test.js — handleNHL's routes: a representative slice of read-proxy routes, all POLL_SECRET-gated mutating/ingest routes (asserting actual KV mutations/merge logic, not just status codes), and the AI-calling routes (/prediction/analyze, /summary/narrative, /team-seasons/head-to-head/narrative). /atom/ingest is covered for both real Atom and plain-RSS-format sources (Session: news ingestion investigation — the route used to assume every source was true Atom, silently parsing 0 items out of any RSS-format feed).src/__tests__/pwhl-routes.test.js — handlePWHL's equivalent: /pwhl/standings's enrichment logic, the POLL_SECRET-gated cache-bust/ingest routes, the AI-calling routes (/pwhl/scout, /pwhl/summary/narrative, /pwhl/prediction, /pwhl/team-seasons/head-to-head/narrative), /pwhl/preview's gameCenterPreview normalization, /pwhl/summary's gameSummary normalization (venue, officials, Head-Coach-only coach filtering, goalie stints), /pwhl/player/career's profile fields (bio HTML-to-plain-text extraction, is_primary photo selection, display_drafts-gated draft data, most-recent-first game log), and /pwhl/transactions's normalization (flat list, ignores the num_results section).src/__tests__/ahl-routes.test.js (added 2026-08, AHL Phase 1) — 's 32-team-plus-historical--entry shape, and through (the 7 foundation routes): cache-hit short-circuit, the L10/streak enrichment, graceful degradation when only the game-log fetch fails, and 's deliberate absence of a hits/faceoff/penalties section. Every AHL and ECHL route is also covered by the characterization suite below.Every NHL, PWHL, AHL and ECHL route is now covered by a characterization suite above. A new route should get a row in its league's suite (ROUTES), then npx vitest run -u for that file to record its snapshots.
npm run deploy
Or push to main: .github/workflows/deploy.yml runs lint and the test suite, then wrangler deploy (repo secrets CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID). PRs run ci.yml: npm audit on shipped deps, lint, tests and a dependency review.
Every query param that reaches a Supabase (PostgREST) URL is read through shared.js's sbParam(value, { type, name }), which validates and URL-encodes it first. This dates from 2026-10, when audit Worker F5 found that ?playerId=8478427%26select%3Dplayer_id replaced /player-shots' column list. The types are int (ids, seasons, game types, limits), abbr (2-4 letter team abbreviations) and id (letters, digits, _ . : -, e.g. the PWHL salaries label 2025-26). Comma lists use sbParamList. A value that doesn't fit answers 400 {"error": "invalid <name>"} before any upstream read. POLL_SECRET is compared in constant time (secretMatches()).
POLL_SECRETEvery POLL_SECRET-gated route (/poll, /cache/bust, /ops/notify, /news/refresh, /social/test, /moneypuck/*, /pp-units/refresh, /summary/generate, /push/test, the */news/ingest, /atom/ingest and /moneypuck/ingest routes, /draft/analyze, /pwhl/cache/bust, /{pwhl,ahl,echl}/news/bust, and force=1 on the prediction routes) reads it through shared.js's readPollSecret(request, url), which checks, in order:
X-Poll-Secret header — the preferred form;x-ingest-secret header (what the pipeline's ingest calls already send);?secret= query param — deprecated (security audit 2026-10-13): Cloudflare's observability logs record the full URL, so a secret in the query string ends up in the logs. It still works because the pipeline's /ops/notify calls use it; it will be removed once nothing sends it.The first one present wins, so a wrong header isn't rescued by a right ?secret=. /draft/analyze never took the query form and still doesn't (readPollSecret(request, url, { allowQuery: false })). Both headers are in the CORS Access-Control-Allow-Headers. Where a route below is written ?secret=, send the header instead:
curl -X POST -H "X-Poll-Secret: $POLL_SECRET" "$WORKER_URL/cache/bust?key=players-search-index"
Set via wrangler secret put <NAME>. Never commit values.
| Secret | Description |
|---|---|
OPENROUTER_API_KEY | AI generation for every narrative/scouting/prediction route (shared.js's generateText()) — see Model provider below |
POLL_SECRET | Protects POST /poll manual trigger and the other secret-gated routes, including POST /ops/notify (the pipeline's failure reports). Send it as an X-Poll-Secret header; ?secret= is deprecated — see Sending POLL_SECRET |
ADMIN_EMAILS | Comma-separated allowlist for GET /admin/health (news-feed source health). Optional — falls back to a single hardcoded default ([email protected]) if unset. See shared.js's verifyAdminUser(). |
GITHUB_DISPATCH_TOKEN | Fine-grained GitHub PAT with Actions: write on ehlersio/eyewall-pipeline and ehlersio/nhl-contracts, used by dispatch.js to start the pipeline's daily workflows, the social posts and the contracts collector (2026-10). Optional: without it the Worker logs once a day and the workflows run on GitHub's own (late) schedule. See Pipeline scheduling |
SUPABASE_SERVICE_KEY | Supabase service-role key (the pipeline's SUPABASE_SERVICE_KEY), used only by shared.js's patchGameLog() to write puck drop and finals into pwhl_game_log/ahl_game_log/echl_game_log (2026-10). Every read still uses the public anon key. Optional: without it the pollers log once per game and skip the write |
VAPID_PRIVATE_KEY | Web Push VAPID private key |
VAPID_PUBLIC_KEY | Web Push VAPID public key |
VAPID_SUBJECT | Web Push contact () |
Bindings (wrangler.toml):
| Binding | Type | Description |
|---|---|---|
CACHE | KV Namespace | All KV read/write operations |
AI_ROUTE_LIMITER | Rate Limit | Per-IP, per-route limit (10 req/60s) on the AI-calling routes with no secret check (counted only on a KV cache miss since 2026-10: a cached answer costs no AI call, and counting it used to 429 a user browsing cached results; force=1 on the four prediction routes needs POLL_SECRET, see Sending POLL_SECRET) — /prediction/analyze, /summary/narrative, /pwhl/summary/narrative, /pwhl/scout, /pwhl/prediction, /team-seasons/head-to-head/narrative, /pwhl/team-seasons/head-to-head/narrative, and (added 2026-08) /ahl/prediction, /ahl/team-seasons/head-to-head/narrative, /echl/prediction, /echl/team-seasons/head-to-head/narrative, and (2026-10, contract C8) /ahl/summary/narrative, /echl/summary/narrative. A single shared binding — checkAiRateLimit(env, request, routeName) (shared.js) keys each check as ${routeName}:${ip}, so one binding covers every league's AI routes without a per-league namespace_id. Provisioned automatically from wrangler.toml at deploy time, no dashboard setup needed. Unrelated to which AI provider those routes call — unaffected by the OpenRouter migration below. |
PUBLIC_WRITE_LIMITER | Rate Limit | Per-IP, per-route limit (30 req/60s, namespace_id 1002) on the public write routes the app calls without a secret: /push/subscribe, /push/unsubscribe, /live-activity/register, /live-activity/start-token (security audit 2026-10-13). checkPublicWriteRateLimit(env, request, routeName) (shared.js), keyed ${routeName}:${ip}; 429 {error: 'Too many requests'} past it. Without the binding (a local wrangler dev that drops it) the routes run unlimited and log one warning per isolate. Provisioned from wrangler.toml like AI_ROUTE_LIMITER. |
AI generation (/summary/narrative, /pwhl/summary/narrative, /pwhl/scout, /prediction/analyze, /draft/analyze, /pwhl/prediction, /team-seasons/head-to-head/narrative, /pwhl/team-seasons/head-to-head/narrative, plus the internal generateGameSummary()/buildPreseasonFallback() helpers — 11 call sites total) went through a Cloudflare Workers AI [ai] binding (env.AI.run('@cf/meta/llama-3.1-8b-instruct-fp8-fast', ...)) until 2026-08. All 11 now call shared.js's generateText(env, {messages, max_tokens}), a plain fetch() to OpenRouter's google/gemma-4-26b-a4b-it — the [ai] binding was removed from wrangler.toml since nothing reads env.AI anymore. /ahl/prediction, /ahl/team-seasons/head-to-head/narrative, /echl/prediction, and /echl/team-seasons/head-to-head/narrative (added 2026-08, after the OpenRouter migration) call generateText() directly — they never went through the old [ai] binding at all. As of 2026-10 there are 13 generateText() calls in src/ (nhl.js 7, pwhl.js 3, hockeytech.js 2, hockeytechPrediction.js 1; the last two each serve more than one league). Since 2026-10 the OpenRouter call has a 25 s AbortSignal.timeout (AI_TIMEOUT_MS), so a slow upstream fails the request (502, as for any AI error) instead of holding it, or the cron tick, open.
Same model swap and reasoning as eyewall-pipeline's ai_client.py was originally — see that repo's README for the full writeup: real accuracy problems found in the old model via side-by-side testing, and why OpenRouter rather than Cloudflare's own hosting of the same new model — its default "thinking" mode burns the whole completion budget and returns empty content, with no working way found to disable it via Cloudflare's endpoint; OpenRouter's own reasoning: {enabled: false} works correctly against the same model. No longer in sync as of 2026-09: ai_client.py switched again, to deepseek/deepseek-v4.1-flash, after a second side-by-side test found more real accuracy problems in Gemma. This repo's shared.js still calls google/gemma-4-26b-a4b-it directly (generateText()) — not yet re-evaluated against this repo's own prompts, which differ from the pipeline's (game summaries, scouting, draft analysis vs. the pipeline's narrative styles). Don't assume parity between the two repos' model choices without checking shared.js directly.
/prediction/analyze's two branches (in-season, and the true-preseason fallback in buildPreseasonFallback()) both read team_elo_ratings (a eyewall-pipeline table, updated nightly by elo_ratings.py) via fetchEloRatings()/eloWinProb(), replacing a hand-tuned scorecard (in-season, with a separately fitted isotonic-calibration patch) and a roster-continuity-dampened variant of the same scorecard (preseason) — see eyewall-pipeline/docs/elo_prediction_model_results.md for the backtest this is based on (Elo beat both prior systems on every metric, in both regimes). One model now serves both regimes; the preseason branch no longer needs roster/continuity data at all, since Elo's own season-boundary regression-to-mean (applied pipeline-side, nightly) already does that job from real outcomes.
The stats in /prediction/analyze's AI prompt (2026-09): record and goals come from the NHL standings; PP%, PK%, shots per game and Corsi come from team_seasons (the standings feed has no special-teams or shot fields, which is how every in-season prompt used to say "PK%: 0.0%"). The preseason fallback runs until both teams have played. After that, each stat is blended with the same team's last-season number while this season's sample is small — (gp × thisSeason + k × lastSeason) / (gp + k), with k = 10 games for shot volume/share, 20 for goal rates, 30 for PP%/PK% (EARLY_SEASON_K). From k games on, this season's number stands alone. The prompt labels each blended number as an early-season estimate. A missing stat reads "not available" and is never replaced with a made-up default. The league-average PP%/PK% line is the mean of every team's team_seasons value (last season's until every team has 30 GP, and only when at least 24 teams have the stat). Head-to-head excludes preseason games.
/pwhl/prediction, /ahl/prediction and /echl/prediction (2026-09) follow the same rules, in one shared implementation (src/hockeytechPrediction.js, buildHockeyTechPrediction(); the blending/formatting helpers above moved from nhl.js to shared.js so all four routes share them). They used to print (pp_pct ?? 0) * 100 and goals_for / (gp || 1), so a team at 0 GP (the pipeline writes 0-GP rows with pp_pct = pk_pct = 0.0) or a missing stat reached the AI as "PP%: 0.0% · PK%: 0.0%" / "GF/GA 0.00", with a Pythagorean score computed from zeros. Now:
getAllPWHLSeasons() (every season, hidden ones included, with startDate; its preseason is its own id), for AHL/ECHL the latest regular season that started before the game's season (getAllAHLSeasons()/getAllECHLSeasons()). During the playoffs that's the regular season just played.gp, same k values as the NHL, and labeled; a team with no prior season (expansion: PWHL DET/HAM/LV/SJS in 2026-27) shows this season's number flagged "small sample". PP%/PK% also count as missing when the row has no power plays / times shorthanded (the pipeline's 0.0 there isn't a real 0%).expHome/expAway are null when either team's goal rates are unavailable (the frontend's share card already skips itself then; the preview popups show that row blank). corsiForPct values are rounded to one decimal.{league}_team_seasons values — last regular season's until every team has 30 GP of a regular season — labeled with season and team count, only when at least 3/4 of the league has the stat (3/4 of that season's rows, and never fewer than 6 teams for PWHL, 24 for AHL, 21 for ECHL).{pwhl,ahl,echl}_team_seasons store pp_pct/pk_pct as 0-1 fractions (checked against production 2026-09-29), PWHL's corsi_for_pct[_5v5] as percentages.generateText() deliberately mirrors env.AI.run()'s exact call/return shape ({messages, max_tokens} in, {response: string} out) so every call site only needed a one-line swap, not a rewrite of prompt-building or response-handling code.
View current secrets:
wrangler secret list
wrangler.toml has no [vars] block. These three settings are read like vars but are set with wrangler secret put (a plain-text dashboard var would be removed by the next wrangler deploy, which runs on every push to main):
| Variable | Description |
|---|---|
VAPID_PUBLIC_KEY | Web Push VAPID public key (a secret, also listed under Secrets) |
APNS_ENV | Native iOS push (a secret): production or sandbox (default when unset). Set to production as of 2026-09-14 — TestFlight and App Store builds register production APNs tokens (Xcode signs archives with the production aps-environment, even though App.entitlements says development), and those tokens only work against api.push.apple.com. Side effect: debug builds run straight from Xcode get sandbox tokens and won't receive pushes while this is production. |
APNS_BUNDLE_ID | Native iOS push: defaults to com.eyewallanalytics.app (the app's bundle ID) if unset. Not set in production; the default applies. |
Binding: CACHE (08c4ef455c584c83a811a96aa3c620ed)
Key patterns:
| Key | TTL | Description |
|---|---|---|
schedule:{ABBR}:{season} | 10min current/next season, 60 days past | NHL team schedule. Re-stamped in place with each game's live state and score from scoreboard:states whenever it changes (the app reads a game as live from here). A /cache/ miss on the older 2-part schedule:{ABBR} shape background-fetches the current season's key (the 2-part key itself is never written) |
roster:{ABBR} | 1hr | NHL team roster (/v1/roster/{team}/current proxy — added 2026-09 alongside the KV caching fix below) |
nhl:injuries:{ABBR} | 1hr | NHL team injury report (Supabase player_injuries, nightly-ingested from ESPN by eyewall-pipeline's injuries.py) |
nhl:callup-watch:{ABBR}:{ahlTeamId} | 1hr | Call-up watch (/nhl/callup-watch) |
nhl:prospects:{ABBR} | 6hr | The NHL's /prospects/{abbr} list, which the call-up watch matches affiliate players against |
nhl:transactions:team:{ABBR} / nhl:transactions:league | 1hr | NHL transactions feed with trade halves paired (Supabase nhl_transactions, nightly-ingested from ESPN by eyewall-pipeline's transactions.py). A failed Supabase read is not cached |
nhl:trades:tree:{txId} | 1hr | One trade's tree (/trades/tree, Supabase trades / trade_assets from eyewall-pipeline's trade_trees.py). Not-found and failed reads are not cached |
nhl:contracts:{ABBR}:{season} / nhl:contracts:player:{id}:{season} / nhl:contracts:players:{season} / nhl:contracts:summary:{season} / nhl:contracts:summary:roster:{season} | 1hr | NHL contracts (Supabase nhl_contracts / nhl_contract_seasons / nhl_contract_clauses / nhl_contract_gaps, mirrored nightly from the open nhl-contracts dataset by eyewall-pipeline's nhl_contracts.py). An empty answer (), a team whose roster could not be fetched, and a failed read are not cached |
| Method | Path | Description |
|---|---|---|
GET | /config/seasons | Live-resolved current NHL + PWHL + AHL + ECHL season ({nhl: {seasonId}, pwhl, ahl, echl} — see Live Season Resolution). pwhl also carries startDate, next (the upcoming regular season, with its own preseason) and preseason (the current season's), each {seasonId, seasonType, startYear, startDate} or null. Consumed by the frontend at app boot and by the pipeline's season_lookup.py. |
GET | /config/seasons/ahl-seasons, /config/seasons/echl-seasons | Every season the league's HockeyTech feed knows about, current and historical: [{seasonId, seasonName, seasonType, startYear, startDate, endDate}], from the same 6hr KV-cached feed as /config/seasons (config:season:{ahl,echl}:seasons). Read by the pipeline's season_lookup.py for a season's type and its game-log date window. 502 when the feed is unavailable (never a guess), same as /config/seasons/pwhl-types. |
GET | /config/seasons/pwhl-types | {seasonId: seasonType} for every PWHL season the HockeyTech bootstrap lists, hidden ones included (getAllPWHLSeasonTypes()), for the pipeline's PWHL scripts to look up an arbitrary season's real type. 502 when the bootstrap is unavailable |
GET | /config/seasons/comparison | Every season with team_seasons/pwhl_team_seasons/ahl_team_seasons/echl_team_seasons rows, per league, with a team count and a comparable flag (strictly more than half of the league's current active team count has a row). Backs the season-over-season comparison feature (Session 64; AHL/ECHL entries added 2026-08 — see Live Season Resolution for the real AHL bug this fixed) — distinct from /config/seasons, which only ever answers "what's current." 1hr KV cache (). PWHL rows also carry ("2025-26", "2025-26 Playoffs", "2023-24 Preseason"), and their is the hockey season's, not the start date's calendar year — see in seasons.js. |
| Method | Path | Description |
|---|---|---|
GET | /cache/:key | Read one of the KV entries the app consumes (primary NHL data path): schedule:*, standings, pbp:*, boxscore:*, landing:*, summary:*, narrative:*, pwhl:narrative:*, ahl:narrative:*, echl:narrative:*, prediction:* (CACHE_ROUTE_READABLE in nhl.js). Any other key is refused (403) — the namespace also holds every push subscription, Live Activity tokens and the APNs JWT, which were readable here until 2026-10-06. 404 on a miss, except standings, which is fetched from the NHL, stored and returned (still 404 if that fetch fails); a schedule:* miss is filled in the background. A key with a malformed % escape is a 400 {error: 'Invalid key'} |
POST | /cache/bust?key= | Delete one KV entry so the next request rebuilds it from live code. POLL_SECRET-gated; GET is refused (405) so a stray link or prefetch can't delete anything. Returns { key, busted } — busted: false means the key wasn't cached, not that the call failed. Refuses ops:*, la:*, health:*, alerts:*, apns:*, push:* and any *:override (403, worker.js's PROTECTED_KV_PREFIXES/isProtectedKvKey()): those hold real data — owner ops devices and dispatch markers, Live Activity tokens and start lists, poller/ops/news health records, the recent-alerts log, the APNs JWT, every push subscription, and deliberately-pinned season overrides — so busting them would destroy data rather than refresh it. (Before 2026-10-13 only push:* and *:override were refused.) Added 2026-09 after the /players-search-index paging fix deployed correctly but stayed invisible for its 6-hour TTL. |
GET | /news?team= | Team news feed |
GET | /schedule?team= | Team schedule |
GET | /roster?team= |
poll() (nhl.js, not an HTTP route) runs every cron tick: it refreshes CAR's schedule, reads the league-wide score/now (live:gameIds, plus CAR's own live:gameId), stamps each changed game's state onto both teams' cached schedules (scoreboard:states), and for each live game writes pbp:/boxscore: (180 s) and pushes to the teams' followers. The pushes are game start, period start, period end (at the intermission), goals (scorer, strength and shot type; the other side gets lead-aware copy), hat tricks, one power-play alert per power play (none when a stoppage's penalties offset; readable penalty text from penaltyText.js), a pulled goalie (third period on, by the trailing team), and the final/win. End-of-period and final pushes link to that team's summary. It also starts and updates Live Activities (liveActivity.js) and records every alert for the bell (alerts:recent:NHL). Each finished game gets its final PBP/boxscore and one game-over push (push:gameover:{gameId}). Only for CAR's own games does it also generate summary:{gameId} (30 days) and try the X post. Standings are refetched once the stored copy is 5 minutes old.
| Method | Path | Description |
|---|---|---|
GET | /pwhl/standings?season= | League standings + L10/streak from pwhl_game_log.ot/.shootout: l10W/l10L/l10OTL, and streakType 'W', 'L' (regulation) or 'OT' (OT/SO losses) — since 2026-10; before that OT/SO losses extended an 'L' streak. season may be any season id: its own type is read (getAllPWHLSeasonTypes()), so a playoff id (9) serves the playoff teams' rows — until 2026-10-08 it filtered season_type=regular and answered [] |
GET | /pwhl/players?teamId=&season= | Roster, skater + goalie stats. The Roster tab list leaves out players the pipeline marked off the team's roster (on_roster=not.is.false, sbRosterRows(); reads unfiltered until eyewall-pipeline's docs/sql/2026-10-06_on_roster.sql has added the column) |
GET | /pwhl/shots?teamId=&season= | Shot events for team heat map |
GET | /pwhl/team-season-summary?teamId=&season= | Season-aggregate SOG/blocks/hits/penalties/faceoffs (car vs. opp) + PP%/PK% for the Shot Map's "All N" summary cards (Session 80). Counts only, aggregated server-side against pwhl_shot_events/pwhl_pbp_events for the team's own completed games — not raw rows like /pwhl/shots |
GET | /pwhl/schedule?teamId=&season= | Team schedule. A game with an Elo probability in pwhl_game_win_probs (eyewall-pipeline's hockeytech_elo.py) carries winProb: { home, away, source: 'elo' } (2026-10) |
GET | /pwhl/roster?teamId= | Bare roster (name + jersey) |
GET | /pwhl/game-box?gameId= | Per-player box score (skaters + goalies) for a completed game — box-score popup (Session 50). Every row carries player_name, from the game's own HockeyTech gameSummary lineup (so a player who has since changed teams is still named), then by id; null when neither knows the player Also and (2026-10, ): is — every goal in the game's , in order, with the skaters on the ice from and //// — and until that table has rows for the game (the AHL/ECHL tables arrive with eyewall-pipeline's goal-on-ice migration; a missing table or failed read never fails the box score). is from ; is or — the feed records only whether it scored, never a save versus a miss. KV key . |
Added 2026-08 across 6 phases (eyewall-poller#69–#74) — see AHL & ECHL above for the phase breakdown, the data-shape ceiling relative to PWHL, and the real bugs found while building these.
| Method | Path | Description |
|---|---|---|
GET | /ahl/standings?season= | League standings + L10/streak from ahl_game_log.ended_in: l10W/l10L/l10OTL, and streakType 'W', 'L' (regulation) or 'OT' (OT/SO losses) |
GET | /ahl/bracket?season=<playoff id> | Playoff bracket (contract C11) from HockeyTech's own bracket feed (feed=modulekit&view=brackets, found by the 2026-10-07 probe; statviewfeed has no bracket view): { season, format: {label, bestOf[], source} | null, rounds: [{ name, bestOf, series: [{ id, name, top: {teamId, seed, wins}, bottom, status: 'scheduled'|'live'|'final', winnerTeamId, games[] }] }], source: 'feed' }. Seeds are the final division ranks of the regular season before (HockeyTech view=teams&groupTeamsBy=division); the feed has none, and leaves winner blank even for finished series, so a series is final when a side reaches the round's wins, 4 wins, or plays in a later round. Without season, the latest playoff season that has started. A season with no rounds yet is rounds: [], uncached; 502 when the feed fails. 1 h KV. Formats (series lengths, qualifiers, byes) live in hockeytechBracket.js PLAYOFF_FORMATS, only as verified from the league's own rules (AHL: 2026) |
GET | /ahl/bracket/projected?season=<regular id> | "If the playoffs started today": the live division standings cut and paired by the verified format for that season's playoffs — { season, format, rounds: [{ name, bestOf, series }], byes: [{teamId, seed, division}], source: 'standings' }, seeds and matchups only (0-0, scheduled). Without season, the latest regular season. rounds: [] with reason: 'format-unverified' (no PLAYOFF_FORMATS entry: the AHL hasn't published its 2027 qualification rules, and 2026-27's realignment changes the per-division counts) or 'no-games', uncached. 1 h KV |
GET | /ahl/schedule?teamId=&season= | Team schedule. A game with an Elo probability in ahl_game_win_probs (hockeytech_elo.py, written for today's and tomorrow's games) carries winProb: { home, away, source: 'elo' } (2026-10); none when the table has no row or can't be read |
pollAHL (built by hockeytech.js, not an HTTP route) runs from worker.js's per-minute scheduled() alongside NHL/PWHL/ECHL's own poll functions — today's slate is game_log plus any game today only the scorebar has (2026-10; the ECHL preseason got no pushes before) — push notifications for game start, period start, goals (with hat-trick detection), power plays, pulled goalies (only an extra-attacker pull: 3rd period, last 10 minutes, by the trailing team, goalie not already back — not the delayed-penalty trips to the bench HockeyTech logs the same way; isExtraAttackerPull() in hockeytechGame.js, shared with pollPWHL) and the final score, gated on env.VAPID_PRIVATE_KEY existing. There is no calendar gate (2026-10): the Oct–June month window used to skip the poll entirely, which hid the late-September preseason, and out of season the poll costs one empty game_log read and one cached scorebar read a minute. On a day with games it also reads the live scorebar fresh from HockeyTech each minute (since 2026-10-19), refilling the {league}:scorebar KV copy the routes read. At puck drop and again at the final, once each, it also writes the game's live state back into {league}_game_log (see Finals written by the Worker). The final-score push goes out once, and only for a game the poll followed live — a game that had already ended the first time it's seen (e.g. right after a deploy) gets none. (Before 2026-09 it never fired at all, for PWHL either: the poll only handed live games to the per-game handler.)
Added 2026-08 across the same 6 phases as AHL (eyewall-poller#75–#81) — see AHL & ECHL above. Every route below is a direct port of its AHL equivalent (same query shapes, same KV caching shape) except where noted.
| Method | Path | Description |
|---|---|---|
GET | /echl/standings?season= | League standings + L10/streak, OT/SO losses split out the same way as /ahl/standings |
GET | /echl/bracket?season=<playoff id> | Same as /ahl/bracket (ECHL rounds come named by the feed, e.g. "Division Semifinals"); Kelly Cup format: top 4 per division, 1v4 / 2v3, every round best-of-7 (2026 and 2027 entries) |
GET | /echl/bracket/projected?season=<regular id> | Same as /ahl/bracket/projected; empty (no-games) until 2026-27 opening night |
GET | /echl/schedule?teamId=&season= | Team schedule, with winProb from echl_game_win_probs the same way as /ahl/schedule |
GET | /echl/roster?teamId= | Bare roster (name + jersey) |
GET | /echl/players?teamId=&season= | Roster + skater/goalie season stats, name-enriched. No row cap, same as /ahl/players |
GET | /echl/league-players?season= | All teams' skater + goalie season stats (Leaders tab) Traded players combined, same as /ahl/league-players. |
GET | /echl/shots?teamId=&season= | Shot events for team heat map. Only shot/goal event_type rows exist |
GET | /echl/game-shots?gameId= | Both teams' shot events in one game, in game order, for the shot map's single-game view (/echl/shots holds one team's shots only) |
GET | /echl/team-season-summary?teamId=&season= | Season-aggregate SOG + PP%/PK%. Same deliberate omission of hits/blocked/faceoff/penalties as /ahl/team-season-summary — no echl_pbp_events table, no blocked_shot event type |
GET | /echl/player/landing?id=&season= |
pollECHL (built by hockeytech.js) runs from the same per-minute scheduled() as AHL's, with identical push-notification event coverage.
Added 2026-10 (audit 2026-10-06 §4/§7): failures used to go nowhere — pipeline "Notify on failure" steps were echos, and a cron tick that threw every minute left /health saying ok: true. (/health now shows each league's lastOk; the error text is on /admin/health.)
curl -fsS -X POST "$WORKER_URL/ops/notify" -H "X-Poll-Secret: $POLL_SECRET" -H 'content-type: application/json' -d '{"source":"<workflow-file>","status":"failure","title":"<Workflow> failed","body":"<run url>","url":"<run url>"}'. (They send ?secret=$POLL_SECRET until the pipeline switches to the header; the Worker accepts both.)ops.js's leagueInSeason(), from the league's own season dates) whose health:cron:<league>.lastOkAt is more than 15 minutes old raises worker-cron-<league>, and records an ok once it recovers.ops:subs, registered from the app's hidden /admin/health page ("Send ops alerts to this device"). Separate from push:subs, so ops alerts never reach users. One push per source per 30 minutes; every report is still recorded.GitHub's on: schedule starts the pipeline's workflows 3-7 hours late, and the morning AI predictions landed after afternoon puck drops (audit 2026-10-06 §4). Since 2026-10 the Worker's own cron, which runs on time every minute, starts them with a workflow_dispatch (src/dispatch.js):
Workflow (eyewall-pipeline) | Dispatched at (UTC) |
|---|---|
nightly.yml | 07:00 |
pwhl-nightly.yml | 07:20 |
ahl-nightly.yml | 07:40 |
echl-nightly.yml | 08:00 |
moneypuck-ingest.yml | 10:00 |
ai_pipeline.yml | 14:00 |
ops:dispatched:<workflow>:<date> marker POSTs https://api.github.com/repos/ehlersio/eyewall-pipeline/actions/workflows/<workflow>/dispatches with {ref: 'main'}. A 204 writes the marker (36h), so the workflow is dispatched at most once per UTC day. A failure is retried 10 minutes later.health:ops:dispatch-<workflow> (status ok/failure), shown on /admin/health.on: schedule as a fallback. A scheduled run skips itself if a run of that workflow already succeeded that UTC day, and a dispatched run never skips.GITHUB_DISPATCH_TOKEN secret. Without it nothing is dispatched (logged once a day) and the GitHub schedule carries on as before. The first day the token is set, every slot already past that day is dispatched on the next tick.Since 2026-10-15 the Instagram + Facebook posts (social-posts.yml) are dispatched the same way, and its GitHub cron list stays as the late fallback. GitHub had been starting them 4-5 hours late (the 15:30 UTC winners card went out near 19:30 UTC on 2026-10-14).
SOCIAL_SLOTS in dispatch.js mirrors the schedule comment at the top of social-posts.yml: 18 kinds, each with a first slot and a backup an hour later, some only on certain UTC weekdays (days, 0 = Sunday, as cron). Keep the two in sync when a post is added or moved.{ref: 'main', inputs: {kind, dry_run: 'false'}}. The workflow's dry_run defaults to true on a dispatch, so leaving it out would render without posting.ops:dispatched:social-posts.yml:<kind>:<HH:MM>:<date>, so the backup is dispatched even though the first slot was. That keeps the workflow's design: a backup run retries a first run that exited 1 on stale data, and does nothing if the post is already in social_posts.ops:dispatch:spacing:social-posts.yml). The workflow's concurrency group keeps one run pending and cancels it when another queues, so slots that come due together go out a few minutes apart, earliest first. Runs take under 3 minutes and scheduled slots are at least 15 minutes apart.health:ops:dispatch-social-posts.yml:<kind>, so one post's status never overwrites another's on /admin/health.social-posts.yml hours later finds the post already published and exits ("Already published everywhere -- nothing to do"). It needs no skip step of its own.Since 2026-10-23 the Worker also starts update-contracts.yml in ehlersio/nhl-contracts, the open contracts dataset's collector (NHL.com Forge, ESPN, PuckPedia's Bluesky feed, then a publish to the contracts release).
kind: 'hourly', 30-minute window), plus :20, :35 and :50 from July 1 to 15 UTC (kind: 'july', 10-minute window, dates: ['07-01', '07-15']): free agency opens July 1 and NHL.com's Forge lists hold only 25 items, so a busy hour can overflow one.repo; these go to https://api.github.com/repos/ehlersio/nhl-contracts/actions/workflows/update-contracts.yml/dispatches with {ref: 'main'}. GITHUB_DISPATCH_TOKEN needs Actions: write on that repository as well (a fine-grained PAT lists its repositories explicitly).ops:dispatched:update-contracts.yml:<kind>:<HH:MM>:<date>); the latest attempt per kind is in health:ops:dispatch-update-contracts.yml:<kind>.Since 2026-10 the HockeyTech pollers (pollPWHL, pollAHL, pollECHL) write a game's live state into {league}_game_log themselves, instead of waiting for the pipeline's live-score-refresh.yml, which GitHub ran 3-4 times a day rather than every 5 minutes. Two writes per game, never one per tick, each from the same gate that sends that push:
PATCH {league}_game_log?game_id=eq.<id> {home_score, away_score, game_state: 'Live', game_status_code}.{home_score, away_score, game_state: 'Final', game_status_code: 4} plus ended_in ('OT'/'SO'/null) for AHL/ECHL, or ot/shootout booleans for the PWHL. Since 2026-10-19 this happens at GameStatus 3 'Unofficial Final', minutes before 4; 3's status text doesn't say OT/SO, so that comes from the play-by-play then (shootout attempts, or play in an overtime period). The move from 3 to 4 sends and writes nothing more. These are the same columns each league's live refresh in eyewall-pipeline writes.So /standings' L10/streak, /lastgame and every other game_state=eq.Final read see a final within a minute. It uses the SUPABASE_SERVICE_KEY secret with Prefer: return=minimal. Without the secret it logs once per game and skips. A failed write is logged and never costs the push. The NHL side needs nothing like this, because its finals come from the NHL's own API.
Most of this is now automatic (2026-07) — see Live Season Resolution. What's left:
NHL_SEASON secret → new season stringPWHL_CURRENT_SEASON in pwhl.jsseasons.jsPWHL_TEAM_CODES in pwhl.js includes DET=10, HAM=11, LV=12, SJS=13)carAbbr/teamId): stale keys from a prior season expire on their own (NHL 30 days, PWHL 24 hr, AHL/ECHL 30 days). No change needed.pwhl.js's PWHL_TEAM_CODES map. Also worth checking the pipeline's pwhl_stats.py/pwhl_salaries.py for their own separate team-ID maps — those don't share this file's map and need the same addition independently (found the hard way in 2026-07: three separate places across two repos enumerate PWHL team IDs).20262027 verified 2026-09-11; fixes in #144, #151, #152). Each fall, compare /config/seasons (or /health's seasons) with each league's real schedule as soon as league pages look empty.ahl.js's AHL_TEAM_CODES or echl.js's ECHL_TEAM_CODES — same "multiple independent per-league team-ID maps, no shared table" situation as PWHL's bullet above, not yet hit for either league but worth remembering when it is./echl/bracket/projected stays no-games until then.ios/: native push, the Live Activity widget's GameActivityAttributes.swift)SHOT_AREAS/shotArea define the NHL shot areas the app bins /pwhl/league-goalie-shots into, and name the areas /nhl/edge/goalie returns)EyeWall Analytics is an independent, fan-built analytics project and is not affiliated with, endorsed by, or sponsored by the National Hockey League (NHL), the Professional Women's Hockey League (PWHL), or any of their member teams. All team names, logos, and related marks are the property of their respective owners and are used here for informational and editorial purposes only.
gw_goalspp_assistssh_assistsgoalie_id: null — a real structural difference from PWHL's feed, which synthesizes its "goal" rows from a richer "shot" event that does carry goalie info. There's no /ahl/goalie-shots or /echl/goalie-shots route (unlike /pwhl/goalie-shots) — building one here would silently under-count every goal allowed, so it's an honest "not available" on the frontend instead of a misleading stat./news/latest/today, /live/:gameId, plus pollAHL/pollAHLGame/broadcastAHL and pollECHL/pollECHLGame/broadcastECHL wired into worker.js's per-minute scheduled() (AHL #74, ECHL #81). Both leagues' PBP normalizers were confirmed live against a real completed game (AHL game 1028925; ECHL game 24296, 81 events) — this resolved the original investigation's "unverified, assumed PWHL shape" caveat for the penalty/goalie_change event shapes on both leagues.AHL_TEAM_CODESBRI/ahl/standings/ahl/team-season-summary/ahl/team-season-summarysrc/__tests__/hockeytech-leagues.characterization.test.js (added 2026-09, ahead of merging ahl.js/echl.js into one shared implementation) — runs every AHL and ECHL route, pollAHL/pollECHL, and fetchAHLNews/fetchECHLNews against one shared fixture set (keyed by table suffix and HockeyTech view, so the same fixtures drive both leagues) and snapshots what each does: response status/body, every upstream request (URL, Range, HockeyTech Referer, the AI prompt), and every KV write with its TTL. Also asserts that repeat calls are served from cache, missing params return 400, and a Supabase outage returns 502. The date is faked to an in-season January evening so output is deterministic. The snapshots are the refactor's safety net: a snapshot diff means behavior changed — explain it in the PR rather than updating the snapshot blindly. The poll tests also pin the game-over push: sent once for a game the poll followed live, never for one already final the first time it's seen.src/__tests__/pwhl-routes.characterization.test.js (added 2026-09, ahead of moving pwhl.js onto the shared route helpers) — the same snapshot approach for every pwhl.js route, pollPWHL and fetchPWHLNews: response status/body, every upstream request, every KV write with its TTL, and the Supabase-down, HockeyTech/AI-down and missing-param paths. Covers the 11 routes nothing tested before (/players, /shots, /schedule, /roster, /lastgame, /pbp, /salaries, /league-players, /player-shots, GET /news, /today). Same rule: a snapshot diff is a behavior change to explain in the PR.src/__tests__/nhl-routes.characterization.test.js (added 2026-09, ahead of moving nhl.js onto the shared route helpers) — the same snapshot approach for every handleNHL route, plus the background-fetch routes (/news, /cache/:key) and the cold-miss /schedule fetch and push subscribe/unsubscribe: response status/body, every upstream request, every KV write with its TTL, and the Supabase-down, NHL-API/AI-down and missing-param paths. Covers the 16 routes nothing tested before (/goalie-shots, /team-lines, /game-xg, /game-log, /xg-trend, /power-rankings, /game-predictions, /game-summary, /player-scouting, /team-skaters, /players-list, /special-teams, /draft/rankings, /draft/picks, /draft/order, /player/landing). poll()'s push notifications stay covered by nhl-routes.test.js.src/__tests__/hockeytech-prediction.test.js (added 2026-09) — hockeytechPrediction.js's helpers: which season is "last regular season" for PWHL (by id, skipping the hidden preseason) and AHL/ECHL (by start date, from the real 2026-09 season list), and how a team_seasons row becomes prompt stats (units; 0 GP, a null column or no power plays is missing, never 0). The routes' prompts — both teams at 0 GP, blending, one team at 0 GP, missing stats, expansion teams, league average, preseason and playoff games — are covered in pwhl-routes.test.js and ahl-routes.test.js.src/__tests__/shared-route-helpers.test.js (added 2026-09) — shared.js's route helpers: cachedJson (cache hit, miss-then-store with its TTL or a TTL computed from the data, a Response from the builder returned uncached), sbRows/sbRowsOr (auth plus extra headers, the 502 on a failed read, the optional-read fallback), and sbError()'s "Supabase error" default, and (2026-10) plainNarrative()'s heading/section/markdown cleanup of AI narratives.src/__tests__/hockeytech-real-games.test.js and pwhl-real-games.test.js (added 2026-10) — routes and alerts replayed against real games (src/__tests__/fixtures/ahl-*, pwhl-*): box scores and PBP named from the game's own lineup (AHL 1029013, PWHL 329 and 328), the goalie-pulled alert polled after every event of AHL 1029094 and PWHL 241 (only the late extra-attacker pulls alert), OT/SO losses in standings and prediction streaks, /ahl/live for the 1029078 shootout, and /ahl/players returning all of Hershey's 2025-26 skaters and goalies.src/__tests__/pwhl-standings-streak.test.js (added 2026-10) — /pwhl/standings L10/streak on the real 2025-26 game log (fixtures/pwhl-2025-26-game-log.js): OT/SO losses (ot/shootout) as their own 'OT' result, cross-checked against pwhl_team_seasons.ot_losses. The PWHL season labels (getAllPWHLSeasons()) are checked against HockeyTech's real season list in seasons.test.js.src/__tests__/hockeytech-traded-and-today.test.js (added 2026-10) — traded AHL players (Graeme Clarke, Laurent Brossoit, 2025-26) combined in /ahl/league-players and /ahl/player/landing from both the per-team and league-wide row shapes, traded goalies' GAA from the real whole-minute rows (fixtures/ahl-2025-26-traded-goalies.js: Brossoit, Shepard), the 6-day /today window, and /echl/today finding the 2026 preseason and the 2026-27 opener from the real scorebar before echl_game_log has them.src/__tests__/hockeytech-percentiles.test.js (added 2026-10) — /{ahl,echl}/player/percentiles and /{ahl,echl}/goalie/percentiles for both leagues: the PWHL shape plus rateBasis, the requests and KV keys, season/latest/traded-player row choice, the {league}_player_xg fallback, and the empty shape (200, uncached) while the tables don't exist yet.src/__tests__/hockeytech-bracket.test.js (added 2026-10) — contract C11: hockeytechBracket.js and /{ahl,echl}/bracket, /{ahl,echl}/bracket/projected on real HockeyTech payloads (fixtures/hockeytech-brackets.js): the 2026 Calder Cup and Kelly Cup brackets (round names, series lengths, seeds, winners, live/scheduled series), and the projection rebuilding both leagues' real 2026 first rounds from the final 2025-26 standings; empty seasons, 0-GP standings, an unverified format and feed failures.src/__tests__/hockeytech-game-detail.test.js (added 2026-10) — contract C6: hockeytechGameDetail.js's goal/penalty-shot/win-probability builders, goals/penaltyShots on /{pwhl,ahl,echl}/game-box (merged with a real-shaped gameSummary, missing or failing tables) and winProb on the three schedule routes.src/__tests__/hockeytech-narrative.test.js (added 2026-10) — contract C8: /{ahl,echl}/summary/narrative for both leagues (cache hit with no rate-limit check, rate limit on a miss, the :fr key and French instruction, OT key normalisation, 400s, 502s without caching) and its prompt checked against the real AHL game 1028986 (CLT 8-1 SPR), plus /cache/ serving the new keys.src/__tests__/pwhl-poll.test.js (added 2026-09) — pollPWHL's game-over push, same two cases as the AHL/ECHL poll tests above, plus (2026-10) the slate read covering every schedule season id (a preseason game in October gets its push; the poll used to filter on the current regular season only, behind a Nov–Jun month gate). Until 2026-09 that push never fired for any HockeyTech league, because the poll only handed live games to the per-game handler.src/__tests__/apns-push.test.js (added 2026-09, native iOS push) — subId()'s token-over-endpoint precedence, sendPush()'s platform dispatch, and sendAPNsPush() itself: real ES256 JWT signing + the actual APNs request shape (host selection by APNS_ENV, apns-topic/apns-push-type headers, aps.alert + merged custom data body), the config-missing guard, 410→'expired' mapping, and the KV-cached-JWT reuse path. Uses a disposable P-256 keypair generated fresh per run (Node's crypto.generateKeyPairSync), not a real APNs credential. Real delivery is exercised in production (TestFlight/App Store builds, APNS_ENV=production).src/__tests__/http-cache.test.js (added 2026-10) — withHttpCache(): max-age from a KV-backed route's TTL (and the one-hour cap), /nhl/today and a live /cache/ read at LIVE_MAX_AGE, the 30 s default, /alerts/recent's own header, a 304 on a matching If-None-Match (weak comparison), and no-store on a POST, 4xx/5xx and owner requests (secret=, Authorization, X-Poll-Secret, x-ingest-secret).src/__tests__/league-odds-rankings.test.js (added 2026-10) — contracts C10/C12: /{pwhl,ahl,echl}/playoff-odds and /{pwhl,ahl,echl}/power-rankings for all three leagues (shapes, requests, stale, KV keys incl. :fr, limit cap) and their empty shapes with the table missing, no rows, a missing narratives table, and a failed read (200, uncached, never 5xx).src/__tests__/penaltyText.test.js (added 2026-10) — penaltyText.js, the readable penalty text in the power-play push and the Live Activity's last event (same wording as eyewall-analytics' utils/penaltyText.js): a bench minor reads "Bench minor · Delay of game (unsuccessful challenge) · 2 min · served by Taylor Hall" rather than naming the player serving it as the offender, a name the roster lacks is left out, and an unknown descKey reads as its own words. Fixtures are the real penalty plays of game 2025021237 (src/__tests__/fixtures/).src/__tests__/security-inputs.test.js (added 2026-10, security audit 2026-10-13 findings 3-7) — every rejection the audit asked for: /push/unsubscribe with {}/{"token":null}/bad JSON (400, subscriptions untouched) and removing only the matching entry; /push/subscribe's endpoint allow-list, keys, iOS token, teamAbbr/teams/prefs checks, the MAX_PUSH_SUBS cap (429 for a new entry, updates still allowed) and the PUBLIC_WRITE_LIMITER 429; broadcastToTeam() dropping a subscription after PUSH_FAILURE_LIMIT failures in a row (not when a whole platform fails); /live-activity/* token format, per-IP caps and rate limit; /news/latest with team=(A+)+$ (400, no fetch or KV write) and teamFilterKeywords(); /cache/%; and the narrative routes in all four leagues (unknown/oversized team, team not in the game, 409 for a game or period not over, prompt text cleaned and team names from the Worker's own maps).src/__tests__/live-activity.test.js (added 2026-10) — liveActivity.js for the PWHL/AHL/ECHL: the HockeyTech ContentState on real AHL game 1029093 (fixtures/ahl-1029093-pbp.js: shots on goal 31-33 from the PBP's shot events, the box score's; the OT final; a live game's period, clock, intermission and last penalty; a bench minor), scorebarIntermission()'s layers (the flag, an intermission GameStatusString, a run-out clock in a live period; never the shootout, a final or a pre-game row; the narrative gate and a priority-10 push on the same signal), the 2026-10-18 scorebar sequences (ECHL 20:00 3rd before the puck drops as "2nd INT" with no extra push, period 1 at 20:00 not an intermission, AHL status 10, status 3 'Unofficial Final' as final, endedInFromPlays()), fetchScorebar({ fresh }) (past the KV copy, refilling it, falling back to it), pollAHL/pollPWHL reading the scorebar fresh each tick, an Unofficial Final ending the activity once with OT from the play-by-play, period names across the leagues' real scorebar finals (AHL 2OT, ECHL OT2, PWHL OT3, a shootout left on period OT), fetchScorebar()'s new fields (laid on only with withClock), scorebarLiveFields() (/today's period/periodType/periodLabel/clock/inIntermission), LA_TEAM_COLORS against each poller's team map, /live-activity/register league keys, /live-activity/start-token's teams (multi-team, unknown dropped, removal on change, disabled, the old team field and the legacy single-team key), and pollAHL/pollPWHL starting, updating and ending a game's activity once.src/__tests__/summary-cache-by-state.test.js (added 2026-10, audit 2026-10-06 W14) — /pwhl/summary and /{ahl,echl}/summary for all three leagues: a live game (or one game_log doesn't know) is cached 60 s under :live with max-age=60, a final 1 h under :final; a copy cached mid-game is never served once the game is final, and a cached final is served without the game_log read.callupWatch, callupWatchRoute, edge, goalReplay, injuryImpact, playoffOdds, probableStarters, projectedLines, scorecard, scratches, trades, transactions, contracts and contracts-routes (the NHL contracts builders and routes, 2026-10), dispatch, ops, admin-health, alert-log, push-targets, sb-params, news-sources, nhl-alerts, hockeytech-season, scheduled/scheduled-dispatch (the cron tick), phase1-correctness and worker-comparison.characterization (/config/seasons/comparison).mailto:...APNS_KEY_ID | Native iOS push and Live Activities (added and set 2026-09; see shared.js's sendAPNsPush()): the Key ID for the APNs Auth Key (.p8) from Certificates, Identifiers & Profiles → Keys on the Apple Developer portal |
APNS_TEAM_ID | Native iOS push: Apple Developer Team ID (already on file for code signing — MHTBPZT59D, see ios/App/App.xcodeproj/project.pbxproj's DEVELOPMENT_TEAM) |
APNS_AUTH_KEY | Native iOS push: the .p8 auth key's full PEM text (-----BEGIN PRIVATE KEY-----...) |
NHL_SEASON | No longer read anywhere in this repo as of 2026-07. Season resolution is entirely handled by seasons.js, whose own fallback is a hardcoded constant, not this secret. Kept here only because eyewall-pipeline's db.py still reads a secret of the same name as its fallback — that's a separate repo/secret, not this one. Safe to leave this one stale or eventually remove it. |
X_CONSUMER_KEY / X_CONSUMER_SECRET / X_ACCESS_TOKEN / X_ACCESS_SECRET | X (Twitter) OAuth 1.0a for nhl.js's postGameToSocial() (a recap after each CAR final) and /social/test?post=1. Optional: postToX() skips unless X_CONSUMER_KEY and X_ACCESS_TOKEN are set, and in production only the two *_SECRET halves are, so nothing is posted. X posting is on hold (the X API is paid per post); the pipeline's social_posts.py handles Instagram/Facebook. |
unavailable: truenhl:config:cap | 24hr | Salary cap ceiling / floor / minimum per announced season (/config/cap, Supabase nhl_cap_limits). Not cached when empty or failed |
nhl:scratches:{ABBR}:{season or auto}:{gameType} | 1hr | Per-player scratch summary for one team-season (Supabase game_scratches, nightly-ingested by eyewall-pipeline's scratches.py). auto = no explicit season (may fall back to the prior season). A failed Supabase read is not cached |
draft:pick-history:{ABBR}:{sinceYear} | 6hr | A team's picks made + own picks traded away over the last 5 drafts (Supabase draft_pick_history, nightly-refreshed by eyewall-pipeline's draft_history.py). A failed Supabase read is not cached |
nhl:playoff-odds:{ABBR}:{season or latest} | 1hr | A team's simulated playoff odds: latest run, season history, next game-day stakes (Supabase playoff_odds / playoff_odds_game_impacts, written nightly by eyewall-pipeline's playoff_odds.py). Neither a failed read nor an empty result (before the season's first run) is cached |
nhl:injury-impact:{ABBR}:{season or latest} | 1hr | A team's season-to-date man-games + WAR lost to injury, with league averages (Supabase team_injury_impact, rebuilt nightly by eyewall-pipeline's injury_impact.py). Neither a failed read nor an empty result (before the 2026-27 regular season's first game) is cached |
nhl:scorecard | 1hr | The public prediction scorecard: each model's latest live period + backtest (Supabase prediction_scorecard, rebuilt nightly by eyewall-pipeline's prediction_scorecard.py). Neither a failed read nor an empty table is cached |
{nhl,pwhl,ahl,echl}:elo-ratings | 1hr | /elo/ratings?league=: every team's Elo rating + the home advantage (Supabase team_elo_ratings, updated nightly by eyewall-pipeline's elo_ratings.py; {league}_team_elo_ratings from hockeytech_elo.py for the other three). Neither a failed read nor an empty table is cached |
nhl:probable-starters:{gameId} | 1hr | Both teams' goalies and the probability each starts one game (Supabase goalie_start_probs, written nightly by eyewall-pipeline's starting_goalie.py once the game is within 2 days). Neither a failed read nor an empty result (game outside the window) is cached |
nhl:projected-lines:{team} | 1hr | A team's projected lines and D pairs for its next game (Supabase projected_lines, written nightly by eyewall-pipeline's projected_lines.py). Neither a failed read nor an empty result (no projection yet) is cached |
pbp:{gameId} | 180s live (three cron ticks, rewritten every minute) / 1hr final | NHL play-by-play |
boxscore:{gameId} | 180s live (three cron ticks, rewritten every minute) / 1hr final | NHL boxscore |
standings | 15min | NHL standings — poll() refetches once the stored copy is 5 min old (its fetchedAt KV metadata), not every tick, so the key is replaced before it expires. A miss (cold start, or the cron stopped) is fetched on demand by /cache/standings and /prediction/analyze, one shared fetch per isolate |
news:{ABBR} | 30min | NHL team news |
pp_units:{season}:{gameType} | 4hr | PP/PK unit rosters for one season and game type (2 regular season, 3 playoffs; per game type as of 2026-09) — the cron re-reads Supabase only once this lapses; /pp-units/refresh forces a re-read (e.g. right after a pipeline run). Season-scoped as of 2026-09 (was a single flat pp_units:all): the shot map can display a season other than the current one, and one key can only hold one season's units. Only the current season's regular-season units are kept warm by the cron; anything else is filled on first request |
health:cron:{league} | 30d (rewritten every tick) | One poller's last tick (nhl/pwhl/ahl/echl): {lastPollAt, lastOkAt, lastError} plus failingSince while failing. Written by ops.js's trackCron(); served whole on /admin/health, and as {lastPollAt, lastOk} only on the public /health |
health:ops:{source} | none | The latest /ops/notify report from one source (a pipeline workflow file, worker-cron-<league>): {status, title, body, url, at} |
ops:subs | 1yr | Devices that get ops alerts — same entry shape as push:subs ({endpoint, keys} or {platform: 'ios', token}), owner-only via /ops/subscribe |
ops:notified:{source} | 30min | Debounce: at most one ops push per source per 30 min |
ops:dispatched:{workflow}:{YYYY-MM-DD} | 36h | Marker: the Worker dispatched that pipeline workflow on that UTC day (dispatch.js) |
ops:dispatched:social-posts.yml:{kind}:{HH:MM}:{YYYY-MM-DD} | 36h | Marker: the Worker dispatched that social post's slot on that UTC day (one per slot, so the backup an hour later has its own) |
ops:dispatch:retry:{workflow}:{YYYY-MM-DD} | 10min | Back-off after a failed dispatch, so a bad token isn't retried every minute (…:social-posts.yml:{kind}:{HH:MM}:{YYYY-MM-DD} for a post slot) |
ops:dispatch:spacing:social-posts.yml | 5min | A social post was just dispatched; the next one waits, so the workflow's concurrency group never cancels a pending run |
ops:dispatch:no-token:{YYYY-MM-DD} | 36h | GITHUB_DISPATCH_TOKEN is unset; already logged today |
health:ops:dispatch-{workflow} | none | The latest dispatch attempt for that workflow: {status: 'ok'|'failure', title, body, url, at} (same shape as /ops/notify's records) |
health:ops:dispatch-social-posts.yml:{kind} | none | The same for one social post kind (its first slot or backup, whichever ran last) |
gamelog:patch:no-key:{table}:{gameId} | 24h | SUPABASE_SERVICE_KEY is unset and this game's game_log write was skipped; already logged |
alerts:recent:{LEAGUE} | 96h | Every alert the pollers sent for that league (NHL/PWHL/AHL/ECHL) in the last 72h, at most 400: {team, vs, type, title, body, url, at}. Collected during a cron run and written once per league at its end (shared.js's recordAlert/flushAlertLog), for the app's notifications bell |
push:subs | 1yr | Push subscriptions — Web Push (endpoint+keys) and, as of 2026-09, native iOS (platform: 'ios' + APNs device token) share this one array; sendPush()/broadcast() branch on sub.platform. A sub follows one or more teams: teams: [{key: 'NHL:CAR', prefs}] (2026-09, the app's followed teams; teamAbbr/prefs = the first), matched by shared.js's pushTargets() — someone following both teams in a game gets each alert once, from the team higher in their list. At most MAX_PUSH_SUBS (20,000) entries; an entry may carry failures (consecutive failed sends, dropped at 5) |
la:ipcount:{league}:{gameId}:{hash} / la:ipcount:start:{hash} | 8h / 24h | New Live Activity tokens registered from one IP (per game / per day for push-to-start), capped at 50 (LA_MAX_TOKENS_PER_IP: venue Wi-Fi and carrier CGNAT put hundreds of phones behind one IPv4); hash is a truncated SHA-256 of the IP, never the IP itself |
la:tokens:{gameId} / la:tokens:{league}:{gameId} | 8h | Live Activity update tokens registered for one game (NHL keys have no league segment), at most 200 |
la:last:[{league}:]{gameId} / la:started:[{league}:]{gameId} | 8h | Last ContentState pushed to a game's activities / push-to-start tokens already sent a start for it |
la:start:{TEAM} / la:start:{league}:{TEAM} | 90d | Push-to-start tokens following a team, [{token, locale}] — an index only; the token's record decides |
la:startteams:{token} (legacy la:startteam:{token}) | 90d | The teams a push-to-start token follows, ["{league}:{ABBR}"]; a list entry whose record doesn't name the team is skipped and removed (#222) |
live:gameIds / live:gameId | 60s | Every live NHL game id / CAR's own live game id |
scoreboard:states | 24h | Each NHL game's last scoreboard state + score, stamped onto cached schedules |
push:gameover:{gameId} | 24h | NHL game-over push dedup |
social:posted:{gameId} | 7d | X post dedup (unused while the X keys are unset) |
apns:jwt | 55min | Cached APNs auth JWT (ES256, signed with APNS_AUTH_KEY) — Apple asks clients not to mint a fresh one per request; see shared.js's buildAPNsJWT() |
pwhl:standings:{season} | 1hr | PWHL standings |
pwhl:league-averages:{season} | 1hr | PWHL league averages (Team page Advanced tab) |
pwhl:players:{teamId}:{season} | 1hr | PWHL roster + stats |
pwhl:shots:{teamId}:{season} | 6hr | PWHL shot events |
pwhl:schedule:v2:{teamId}:{season} | 30min | PWHL team schedule |
pwhl:news | 25hr populated / 5min empty | PWHL news feed. Written by both /pwhl/news/ingest (nightly pipeline) and fetchPWHLNews() (on-demand cold-cache path) — both now use the same TTL (fixed 2026-08-14, was a 30min/5min mismatch that let the nightly job's fuller article set get silently replaced by the on-demand path's narrower 3-source result once a day). |
pwhl:today:{season} | 60s | Today's PWHL games + status |
pwhl:live:{gameId} | 60s live / 1hr final | PWHL live PBP (60s, not 30 — KV's expiration_ttl minimum, see AHL & ECHL) |
pwhl:gamesummary:{gameId}:final / :live | 1hr final, 60s before | PWHL game summary (goals, MVPs, team stats, venue, officials, head coaches). Final or not is pwhl_game_log + scorebar, as /pwhl/live decides it; max-age follows the TTL, and a copy cached mid-game (:live) is never served once the game is final (shared.js's cachedUntilFinal()) |
pwhl:prediction:elo:{gameId}[:fr] | 30min | /pwhl/prediction |
pwhl:narrative:{period}:{gameId}:{carAbbr} | 24hr | AI period/game narrative per team perspective |
{pwhl,ahl,echl}:playoff-odds:{teamId}:{season|latest} | 1hr | /{league}/playoff-odds (C10); empty/unavailable answers aren't cached |
{pwhl,ahl,echl}:power-rankings:{teamId|all}:{season|latest}:{limit}[:fr] | 1hr | /{league}/power-rankings (C12); empty/unavailable answers aren't cached |
pwhl:transactions:{season} | 24hr | League-wide signings/moves feed |
pwhl:pshots:{playerId}:{season} | 6hr | PWHL player shot heat map data |
pwhl:player:landing:{playerId}:{season|'latest'} | 1hr | Single PWHL player's identity + one season's stat line |
config:season:nhl | 6hr | Live-resolved current NHL season (see Live Season Resolution) |
config:season:pwhl | 6hr | Live-resolved current PWHL season {seasonId, seasonType, startYear} |
config:season:nhl:override | none (manual) | Forces a specific NHL season, bypassing live resolution entirely |
config:season:pwhl:override | none (manual) | Forces a specific PWHL season, bypassing live resolution entirely |
players-search-index | 6hr | Flat NHL+PWHL+AHL+ECHL player list for the global player-search autocomplete |
trivia:{date}:{sport}:{team|'ALL'} | 24hr once populated, 60s if empty/partial | Daily trivia — all three tiers (easy/medium/hard) for one sport+team, one day (Phase 2, Session 92). Short TTL guard mirrors /draft/picks' pattern so a pre-publish snapshot from before the nightly job finishes doesn't get pinned for a full day. |
milestones:{sport}:{team}:{limit}:{season} | 1hr | /milestones' own list cache, one entry per distinct query shape. Includes the live-resolved season (added 2026-08) so a cached entry never survives a season flip. |
milestones:latest:{sport}:{season} | 1hr | Cheapest "is there anything new" check for the frontend's Milestones read-state badge (Phase 2, Session 92) — {latestId, gameDate} only, not the full feed. Season-scoped (added 2026-08) in lockstep with /milestones itself. |
ahl:standings:{season} / echl:standings:{season} | 1hr | AHL/ECHL standings + L10/streak (OT/SO losses split out — see AHL & ECHL) |
ahl:bracket:{playoffSeasonId} / echl:... | 1hr | /{league}/bracket (C11); a season with no rounds yet isn't cached |
ahl:bracket:projected:{seasonId} / echl:... | 1hr | /{league}/bracket/projected (C11); no-games / unverified-format answers aren't cached |
ahl:schedule:v2:{teamId}:{season} / echl:schedule:v2:{teamId}:{season} | 30min | Team schedule |
ahl:roster:{teamId} / echl:roster:{teamId} | 24hr | Bare roster (name + jersey) — rarely changes |
ahl:players:{teamId}:{season} / echl:players:{teamId}:{season} | 1hr | Roster + skater/goalie season stats, name-enriched |
ahl:leagueplayers:{season} / echl:leagueplayers:{season} | 2hr | All teams' skater + goalie season stats (Leaders tab) |
ahl:shots:{teamId}:{season} / echl:shots:{teamId}:{season} | 1hr / 5min empty | Shot events for team heat map — paginated fetch (1000-row Supabase batches), cached whole |
ahl:game-shots:{gameId} / echl:game-shots:{gameId} | 1hr / 5min empty | One game's shots, both teams. Empty until the nightly run ingests the game |
ahl:team-season-summary:{teamId}:{season} / echl:team-season-summary:{teamId}:{season} | 1hr | Season-aggregate SOG + PP%/PK% — deliberately no hits/faceoff/penalties section (no data source, see AHL & ECHL) |
ahl:player:landing:{playerId}:{season|'latest'} / echl:player:landing:{...} | 1hr | Player-popup self-fetch: identity + one season's stat line |
ahl:player:career:{playerId} / echl:player:career:{playerId} | 24hr | Career Regular Season/Playoffs totals, live HockeyTech view=player proxy |
ahl:pshots:{playerId}:{season} / echl:pshots:{playerId}:{season} | 6hr | Single skater's shot heat map data |
ahl:lastgame:{teamId}:{season} / echl:lastgame:{teamId}:{season} | 1hr | Most recent completed game, opponent abbr resolved |
ahl:gamesummary:{gameId}:final / :live (and echl:) | 1hr final, 60s before | Game summary (goals, MVPs, officials, coaches, venue) — hits/faceoff fields stripped. Live/final as for pwhl:gamesummary (game_log + scorebar, the /live route's detection) |
ahl:gcpreview:{gameId} / echl:gcpreview:{gameId} | 30min | Pre-game preview, raw HockeyTech gameCenterPreview passthrough |
ahl:gamebox:v2:{gameId} / echl:gamebox:v2:{gameId} | 1hr | Per-player box score (skaters + goalies), name-enriched |
ahl:pgamelog:{playerId}:{season} / echl:pgamelog:{...} | 1hr | Per-game log for a single player (/{league}/player-game-log; ECHL since the 2026-09 hockeytech.js merge) |
ahl:prediction:elo:{gameId}[:fr] / echl:prediction:elo:{gameId}[:fr] | 30min | Elo win probability + AI narrative (:elo added 2026-09 so point-split predictions cached before the switch expired instead of being served) |
ahl:team-seasons:compare:{teamId}:{seasons} / echl:team-seasons:compare:{...} | 1hr | One team across a comma-separated season list |
ahl:team-seasons:compare-teams:{teamIds}:{season} / echl:team-seasons:compare-teams:{...} | 1hr | Two teams, one shared season |
ahl:team-seasons:head-to-head:{teamIds} / echl:team-seasons:head-to-head:{teamIds} | 1hr | All-time head-to-head across every season on record |
ahl:h2h-narrative:{teamIds} / echl:h2h-narrative:{teamIds} | 24hr | AI narrative layer on the head-to-head stats above |
ahl:narrative:{period}:{gameId}:{teamId}[:fr] / echl:... | 30 days | EyeWall AI period/final narrative per team perspective and language (/{league}/summary/narrative); period is 1-3, OT/2OT.. or game. Readable through /cache/ |
ahl:news / echl:news | 25hr populated / 5min empty | News feed — 3 AHL sources, 2 ECHL sources (see AHL & ECHL); same populated/empty TTL split as pwhl:news above |
ahl:today:{season} / echl:today:{season} | 60s | Today's games with live status |
ahl:scorebar / echl:scorebar / pwhl:scorebar | 60s | HockeyTech's live scorebar (+/-1 day) as {gameId: {game_status_code, game_state, home_score, away_score, ended_in, period_name_short, game_clock, intermission}} (the last three only laid on with withClock: the pollers, the narrative gate and /today's live fields), laid over {league}_game_log rows by /today, /schedule (AHL/ECHL), /live and the push poll. The push poll reads HockeyTech fresh every tick (fresh, with cf: { cacheTtl: 0 }, since 2026-10-19) and rewrites this key, so the routes read a copy at most a minute old; a failed fresh read falls back to it. GameStatus 3 'Unofficial Final' reads as final. game_log's live columns used to come only from the pipeline's live-score-refresh.yml, a */5 GitHub schedule that GitHub fired only every 3–6 hours on 2026-10-03/04, so a finished game kept reading live. Since 2026-10 the pollers also write puck drop and the final there themselves. Not written when every row is already final |
ahl:live:{gameId} / echl:live:{gameId} | 60s live / 1hr final | Live PBP + normalized events. 60s, not 30 — Cloudflare KV's expiration_ttl minimum is 60s (see AHL & ECHL) |
ahl:push:{state|start|period|goal|pen|pull|final}:... / echl:push:{...} | 24-48hr | Per-game push-notification dedup keys (game start, period start, goal, PP, goalie pulled, final) — mirrors NHL/PWHL's own push state-tracking shape, prefixed per league |
config:season:ahl / config:season:echl | 6hr | Live-resolved current AHL/ECHL season {seasonId, seasonType} |
config:season:ahl:override / config:season:echl:override | none (manual) | Forces a specific AHL/ECHL season, bypassing live resolution entirely |
config:season:ahl:seasons / config:season:echl:seasons | 6hr | Cached raw HockeyTech modulekit&view=seasons response backing season-type/season-list lookups |
config:seasons:comparisonlabelstartYeargetAllPWHLSeasons()GET | /players-search-index | Flat {id, name, team, position, sport} list of every NHL + PWHL + AHL + ECHL player, for the frontend's player-search autocomplete. NHL team is resolved from each player's most-recently-updated current-season player_seasons row — unchanged by the Combined Prediction Calibration work's players.team addition (2026-07); that column exists now, but this route's own derivation wasn't revisited here, out of scope for that change. If the live season has zero player_seasons rows at all yet (season flipped ahead of real games, e.g. once the new schedule is released — see Live Season Resolution), NHL entries fall back to one season back and add teamStale: true + teamSeason: "<season>"; a player with no row in either season gets team: null with no teamStale field. PWHL/AHL/ECHL have no season dimension in their players tables (one row per player, current team assignment only) — no staleness concept applies to those three. |
GET | /trivia/today?sport=&team=&locale= | All three trivia tiers (easy/medium/hard) for one sport (nhl, pwhl, ahl or echl; the last two since 2026-10, easy/medium only, from trivia_questions.py --sport ahl|echl; anything else is a 400) in a single response, team optional (adds that team's medium-tier question; omit for easy/hard only) — Daily Trivia (Phase 2, Session 92). Easy/medium require an exact question_date match (nightly-populated by the pipeline's trivia_questions.py; no match means "not published yet," shown as the empty state). Hard is hand-curated with no admin UI, added in batches rather than nightly — its query falls back to the most recent row on or before today (question_date=lte.&order=question_date.desc) instead of an exact match, so it only goes empty if zero hard rows exist yet for that sport, not on every day between curation batches. Correct answer/explanation are included in the response (not split into a post-answer reveal step) — accepted for a low-stakes trivia feature. locale (en/fr, defaults to en) filters all three tiers — see French/English localization below. |
GET | /news/latest?sport=&team= | Cheap "is there anything new" check for the frontend's News read-state badge (Phase 2, Session 92; ahl/echl added 2026-08 alongside their own news feeds — see AHL & ECHL) — reuses the exact news:{ABBR}/pwhl:news/ahl:news/echl:news KV entries /news//pwhl/news//ahl/news//echl/news already populate (pure KV read when warm) rather than a second parallel fetch; triggers the same background warm on a cold cache and returns {latestId: null} for that request. sport must be nhl, pwhl, ahl, or echl. team required for sport=nhl (news is per-team) and must be an NHL team abbreviation (a TEAM_CONFIGS key, any case; anything else is a 400 with no fetch or KV write — an unknown team used to become the news filter regex, security audit 2026-10-13), omitted for sport=pwhl/ahl/echl (one global feed each). |
GET | /milestones?team=&sport=&limit= | Recent milestones (hat tricks, shutouts, SH goals, season/career thresholds) — NHL unless sport=pwhl, ahl or echl (any other sport is a 400 since 2026-10), optional single-team filter, limit default 50 / max 100. Populated nightly by the pipeline's milestones.py (NHL) / pwhl_milestones.py (PWHL) into one shared milestones table (is_pwhl column). AHL/ECHL rows (2026-10, eyewall-pipeline's hockeytech_milestones.py) are read by a sport column (sport=eq.ahl) and the league's HockeyTech season_id; until the table has that column the read 400s and the route answers [] (not cached, max-age=300) instead of a 502 (milestones.js's milestoneScope() holds the per-sport filter for both milestone routes). Scoped to the live-resolved current season (added 2026-08, see Live Season Resolution) — before this, an unfiltered "most recent N" query had no way to age out an old row once the table stopped getting new ones (e.g. NHL offseason), so a leftover milestone from the prior season could sit as the only NHL row indefinitely. |
GET | /milestones/latest?sport= | Same badge purpose as /news/latest, for Milestones — its own minimal limit=1 query since no reusable unfiltered KV entry exists for that table (the real /milestones route's key always includes team+limit+season). gameDate is date-granularity, not a timestamp — milestones has no insertion-time column, accepted as good enough for a boolean seen/unseen badge. Takes the same four sport values and the same per-sport filter (milestoneScope()); before the sport column exists AHL/ECHL answer {latestId: null}, uncached. Season-scoped in lockstep with /milestones — otherwise this could flag "unseen" for an id that no longer appears in the (now season-scoped) list at all. |
GET | /admin/health | News-feed source health (per-source last success/error/timestamps across all 4 leagues) — gated to the app owner, see ADMIN_EMAILS above. Added 2026-09; nothing recorded fetch success/failure before this, every fetch*News()/*/news/ingest//atom/ingest call site now writes a health:<league>:<sourceId> KV record via shared.js's recordHealth(). Consumed by eyewall-analytics's hidden /admin/health page. As of 2026-10 it also returns cron (each league's full health:cron:* record, lastError text included) and ops (every health:ops:* report), opsSubscribers (the ops:subs count, never the subscriptions), and since 2026-10-13 what the public /health used to show: subscribers (the push:subs count), liveGameId and liveGameIds. sources stays news feeds only. Response: {sources, cron, ops, opsSubscribers, subscribers, liveGameId, liveGameIds, checkedAt}. |
POST | /ops/notify | An ops report {source, status: 'failure'|'ok'|'warn', title, body?, url?} (POLL_SECRET-gated: X-Poll-Secret header, or the deprecated ?secret= the pipeline still sends). Stored as health:ops:<source>; a non-ok report pushes to every ops:subs device (tag ops-<source>, url default /admin/health), at most once per source per 30 min. Returns {ok, pushed}. See Ops alerts |
POST | /ops/subscribe | Owner-only (Supabase session, verifyAdminUser): the same body the app sends to /push/subscribe; upserts the device into ops:subs. Returns {ok, count} |
POST/DELETE | /ops/unsubscribe | Owner-only: {endpoint} or {token}. Returns {ok, count} |
Team roster (/v1/roster/{team}/current proxy, KV-cached 1hr — see roster:{ABBR} above). Added 2026-09: the frontend's own getRoster() used to call NHL directly with zero caching, unlike this route's /schedule//standings siblings — every page load was a genuinely fresh live upstream fetch, the root cause of repeated Cypress flakiness against a real, unmocked, uncached third-party call. |
GET | /injuries?team= | Team injury report, read from Supabase's player_injuries table (KV-cached 1hr — see nhl:injuries:{ABBR} above). NHL has no official injuries endpoint, so eyewall-pipeline's injuries.py ingests from ESPN's undocumented API nightly and writes the table this route serves; not season-scoped, unlike /team-lines. Each row: player_id (nullable — unmatched ESPN names are kept), player_name, status, comment, espn_updated_at, plus injury_type/injury_side/injury_detail/return_date (2026-09, any of the four may be null). Degrades to [] on a Supabase read failure rather than a 502. The daily player_injury_history table isn't exposed by any route yet. |
GET | /nhl/callup-watch?team=&ahlTeamId= | Call-up watch (2026-10): who's out on the NHL team, by position group (F/D/G), and the affiliate players next in line. ahlTeamId is the affiliate's HockeyTech id; the NHL→AHL map lives in the frontend (teamHistory.js). A ranking, not a probability (no contract/waiver data, no fitted model). Candidates are affiliate players who hold the team's NHL rights (NHL /prospects, matched by name + birth date, since nothing links NHL and AHL ids) or whom the team's last year of nhl_transactions names (so assigned veterans count). Ranked by AHL points per game over this AHL season and the last regular one (save % for goalies), with ≥5 GP to rank; each carries this/last/combined lines, last 5 games, recalls and the latest move. Per group also upNow (affiliate players on the NHL roster now) and hurt (injured players ESPN files under the NHL team who are on the affiliate: not an open NHL spot); unplaced lists injuries whose position couldn't be found. Affiliate players marked off its roster (ahl_players.on_roster = false) are left out; unfiltered until that column exists. Logic in src/callupWatch.js. |
GET | /transactions?team= / /transactions?scope=league | NHL transactions feed from Supabase's nhl_transactions (eyewall-pipeline's transactions.py, ESPN's NHL transactions feed — the NHL API has none). Team scope returns that team's own entries plus trades naming it (or=(team.eq.X,counterparties.cs.{X})); league scope returns the most recent moves league-wide; both newest first, up to 150 rows. ESPN posts each side of a trade separately, so pairTransactions() (src/transactions.js) merges a trade entry from A naming B with one from B naming A within ±2 days into { kind: 'trade', ids, date, teams, sides: [{team, description}] } (focus team first); everything else is { kind: 'move', id, date, team, category, categories, counterparties, description }. ids/id are the nhl_transactions row ids /trades/tree?tx= takes. Response: { scope, team, items }. team must be a 2–3 letter abbreviation (400 otherwise — it's interpolated into the PostgREST filter). KV-cached 1hr; a Supabase read failure returns empty items and is not cached. |
GET | /trades/tree?tx= | One NHL trade and where every asset went next, from Supabase's trades / trade_assets (eyewall-pipeline's trade_trees.py: ESPN's trade text parsed into players and picks, picks resolved to the drafted player via the NHL's draft records, each asset linked by next_trade_id to the next trade its new team sent it on in). tx is any nhl_transactions id from /transactions' items — the trade containing it is the root. fetchTradeTree() (src/trades.js) follows next_trade_id breadth-first, one Supabase read per level, up to 5 levels / 30 trades (truncated: true past that), and loads the trades that brought the root's outgoing assets in (origins, one level back). Response: { found, root, trades: { <id>: { id, date, teams, via, descriptions, sides: [{ team, received: [asset] }] } }, origins: [{ id, date, teams, assets }], truncated }; an asset is { type, from, to, next, … } with name/position/rights/playerId for players and pick: { year, round, conditional, overall, resolvedYear, resolvedOverall, draftedName, draftedId, note } for picks (note null when resolved, else future / not_traced / several_possible). tx must be digits (400 otherwise — it's interpolated into the PostgREST filter). KV-cached 1hr; a not-found (found: false) and a failed read (unavailable: true) are not cached. |
GET | /contracts?team=[&season=] | NHL contracts (2026-10) from Supabase's nhl_contracts* tables (eyewall-pipeline's nhl_contracts.py, a nightly mirror of the open nhl-contracts dataset, CC0). season is YYYY-YY (400 otherwise), default the live NHL season. A contract row's team is the signing team, so the route reads the contracts of everyone on the team's current NHL roster (roster:{ABBR}, fetched if cold) plus the deals the team signed whose player is off the roster, and marks each row onRoster. One row per player: when the dataset has two rows for one contract (an id-keyed club row and a name-keyed archived-CapFriendly row, a real case) pickContract() keeps the better tier / more sources / id-keyed one and alternates counts the rest, while capHit comes from any of the player's nhl_contract_seasons rows for the season (best tier; capHitSource: season | contract | aav). Row: { contractKey, playerId, name, team, position, onRoster, capHit, capHitSource, capHitTier, aav, totalValue, termYears, startSeason, expiresAfter, yearsLeft, isCurrent, expiryType ('UFA'|'RFA'|null), expiryTypeDerived, expiryStatus, contractType, oneWay, signedAt, tier (A–E), official (A–C), sources, conflicts, boughtOut, endSeasonSigned, clauses: [{kind, from, to, listSize, listType, tier}], alternates }, roster first then cap hit descending. boughtOut is true for a bought-out contract, whose expiresAfter is then the last paid season and endSeasonSigned the season it was signed to run through (null otherwise); these come from nhl_contracts.bought_out / end_season_signed, and until the pipeline migration adding them has run the Worker reads the base columns instead (readContracts() retries once without them and remembers for the isolate), so they read false / null. missing lists roster players with no row (gap from nhl_contract_gaps: no_contract | no_money | no_structure, else no_contract). summary: { rosterCapHit, rosterCount, rosterWithFigure, rosterWithoutFigure, offRosterCount, capLimits: {ceiling, floor, minSalary} | null, expiring: {ufa, rfa, unknown}, deadCap: {total, retained, buyouts, count} | null, capSpaceEstimate, estimateNotes } (2026-10-25: deadCap sums the season's nhl_dead_cap rows for the team, retained salary and buyouts from the dataset's PuckPedia and archived-CapFriendly sources; capSpaceEstimate = ceiling − roster cap hit − dead cap, an estimate with estimateNotes ['no_ltir','no_bonus_overages','no_recapture','partial_coverage'], null when the roster or a figure is unknown; deadCap is the shaped rows { team, kind, playerName, playerId, counterparty, season, capHit, pct, contractKey, eventDate, tier, flags } and deadCapKnown false when the table could not be read, in which case the route still answers without an estimate). Response: { team, season, asOf (dataset_updated_at), rosterKnown, rows, missing, deadCap, deadCapKnown, summary, unavailable }; unavailable: true when the team has no rows (hide the tab). 1hr KV; empty, roster-less and failed answers are not cached. Logic in src/contracts.js. |
GET | /contracts/player/:id[?season=] | Every contract the dataset has for an NHL player id (digits, 400 otherwise), newest first, each with its seasons: [{season, capHit, aav, baseSalary, signingBonus, performanceBonus, totalSalary, minorsSalary, oneWay, clause, tier}] and clauses, plus current, the chosen contract covering season (same shape as a /contracts row). Response: { playerId, season, asOf, current, history, unavailable }. 1hr KV; unavailable and failed reads not cached. |
GET | /contracts/players[?season=] | Every NHL player's contract for the season in one payload, for league-wide surfaces (the contract-value leaderboard needs every skater's cap hit at once, not 32 /contracts calls). The 32 current rosters (roster:{ABBR}, fetched and cached if cold) are joined to the dataset: one row per player who has a contract covering the season or is on a roster today, the contract chosen as /contracts does (roster team preferred), capHit from the season rows, currentTeam / position / onRoster from the roster and team the signing team; roster players with no row carry nulls. Same row shape as /contracts minus clauses, cap hit descending, figures before nulls. Response: { season, basis: 'roster', asOf, capLimits, teamsKnown, players, unavailable }. 1hr KV; not cached while unavailable, a read failed, or a roster is missing (teamsKnown < 32). |
GET | /contracts/summary[?season=][&basis=roster] | Every team's contracts for the season. Default basis: 'signing_team': the deals each team signed (a player traded mid-contract counts for the team that signed him), teams: [{ team, contracts, withFigure, capHitTotal, expiring: {ufa, rfa, unknown} }] by capHitTotal descending. basis=roster (2026-10): each team's roster cap hit, the Team page's number, from the 32 current rosters joined to the dataset: teams: [{ team, rosterCount, rosterWithFigure, rosterCapHit, offRosterCount, expiring, deadCap, capSpaceEstimate }] by rosterCapHit descending, plus teamsKnown, deadCapKnown and estimateNotes (deadCap and the estimate as on /contracts, null while nhl_dead_cap is unreadable); a player counts for the team whose roster he is on today, offRosterCount is the team's signed players on no NHL roster. Both carry capLimits and asOf; neither is a cap-space calculator. 1hr KV (nhl:contracts:summary:{season} / nhl:contracts:summary:roster:{season}); unavailable, failed reads and (roster basis) a missing roster are not cached. |
GET | /config/cap | Salary cap ceiling / floor / minimum salary per season the NHL has announced (Supabase nhl_cap_limits; 2026-27 only today: $104M / $77M / $850K; nothing projected) and the live season's label: { current: '2026-27', seasons: { '2026-27': { ceiling, floor, minSalary } } }. 24hr KV; empty or failed answers add unavailable: true and are not cached. |
GET | /nhl/goal-replay/:gameId/:eventId | One NHL goal's player and puck tracking (NHL EDGE) for the app's goal replay, Video | Tracking (added 2026-09, src/goalReplay.js). The goal's pptReplayUrl is read from the game center landing feed (never built from the ids, and only ever fetched from wsr.nhle.com), then fetched with a browser User-Agent -- the file answers 403 without one, and browsers can't fetch it cross-origin. Response: { available: true, gameId, eventId, hz: 10, goalFrame, attacksRight, scorerId, teams: {home, away}, players: {trackId: {playerId, number, team}}, frames: [{ puck: [x, y] | null, at: {trackId: [x, y]} }] }, positions in play-by-play feet (x = inches_x/12 - 100, y = 42.5 - inches_y/12, checked against 40 goals' shot spots), ~30-60 KB against ~200-250 KB raw. goalFrame is the frame the puck is in the net, same rules as eyewall-pipeline's goal_of_week.py. 404 { available: false } when there's no replay (none before 2023-24; not published yet mid-game). KV-cached (nhl:goal-replay:v1:{gameId}:{eventId}) a year once the game is final, 5 min while live (files are rewritten at the final horn), 60 s when missing. Ids are validated (400 otherwise). |
GET | /nhl/edge/(skater|goalie)/:playerId/:season/:gameType | One player's NHL EDGE tracking stats for the app's player Analytics tab (added 2026-10, src/edge.js). gameType is 2 (regular season) or 3 (playoffs) -- EDGE has no preseason and starts in 2021-22. Skaters: top skating speed, bursts over 20 mph, distance per 60, top and average shot speed, high-danger shots on goal, even-strength offensive-zone time. Goalies: % of games above .900, 5v5 SV% in close games, long-range SV%, plus areas (2026-10): their record in each of the NHL's 17 shot areas ({ shots, goals, savePctg, pct }, keyed by the NHL's area names, savePctg/pct null for an area with no shots; areas null when the NHL has no shot-location data), for the app's goalie area map. Response: { available: true, kind, playerId, season, gameType, gamesPlayed, metrics: { name: { imperial, metric, pct, avg: {imperial, metric} } | { value, pct, avg } | null } } -- every percentile (pct, 0-100) and league average is the NHL's own. 404 { available: false } when the NHL has no EDGE data for that player/season/game type; 502 when the NHL is unreachable (not cached, so the app can retry). KV-cached (nhl:edge:v2:{kind}:{playerId}:{season}:{gameType}) 6 h for the current season, 30 days for past ones; a 404 1 h for the current season, 7 days for past ones. |
GET | /nhl/edge/team/:teamId/:season/:gameType | One team's NHL EDGE stats for the Team page's Advanced tab (added 2026-10, src/edge.js, one team-detail call): offensive-zone time (all and even strength), defensive-zone time, shots on goal, high-danger shots and shooting %, top skating speed, bursts over 20 and 22 mph, total distance skated, hardest shot, shot attempts over 90 mph. Each metric carries the NHL's own rank in place of a percentile ({ value, rank, avg } or { imperial, metric, rank, avg }). Rank 1 is best, which for defensive-zone time means the least. In the playoffs the NHL ranks only the playoff teams. avg is null where the NHL gives no league average. Response has teamId instead of playerId; same 404/502 and cache rules as the player route. |
GET | /nhl/edge/leaders/:season/:gameType | The NHL's own EDGE top 10s for the app's League view leaders (added 2026-10, src/edge.js): fastest skating speed, hardest shot, distance skated (season total) and offensive-zone time (%), all positions. Each row is { playerId, firstName, lastName, team, position, headshot } plus { imperial, metric } (or { value } for zone time). Speed and shot rows add moment: { date, away, home, period, periodType, time }, when the NHL clocked it. The NHL's top-10 rows have no id field, so playerId comes from the end of their slug. A list the NHL 404s on its own comes back []; all four empty is a cached 404. Same cache rules as the player route. |
GET | /scratches?team=&season=&gameType= | Per-player scratch summary for one team-season from Supabase's game_scratches (eyewall-pipeline's scratches.py: the NHL's own right-rail scratch lists, each classified healthy/injured/suspended/unknown against that day's injury snapshot). gameType 2 (regular season, default) or 3 (playoffs) — never pooled. Response: { team, season, gameType, stale, players: [{ player_id, player_name, total, healthy, injured, suspended, unknown, last_date }], totals, games_with_scratches, classified }, players most-scratched first (summarizeScratches(), src/scratches.js). With no season and no rows yet for the live season, falls back to the prior season with stale: true. classified: false means every scratch is unknown (all games before 2026-09-12), so a healthy/injured split isn't meaningful. team (2–3 letters), season (8 digits) and gameType are validated (400 otherwise). KV-cached 1hr; a failed read returns an empty summary and is not cached. |
GET | /player-shots?playerId=&season=&team=&gameType= | One skater's shots for the player popup's shot map, from Supabase's shot_events. gameType 2 (regular season, default) or 3 (playoffs), filtered on shot_events.game_type, a computed field on game_id (eyewall-pipeline's docs/sql/game_type_column.sql); preseason is never returned, and anything else is a 400. Until 2026-09 all three game types came back together. KV-cached 1hr per game type. |
GET | /nhl/shots?team=&season= | Every shot from every completed game team played in season (both teams'), from Supabase's shot_events, for the shot map's "All N" season view. The team's game ids come from the NHL schedule, not car_game. Each row: game_id, event_id, team, x, y, event_type, period, time_in_period, shot_type, plus ids and names for the shooter, goalie, both assists (goals) and the blocker (blocked shots): player_id/shooter_name, goalie_id/goalie_name, assist1_id/assist1_name, assist2_id/assist2_name, blocker_id/blocker_name. The names (added 2026-10) come from the players table in one lookup per response; an id it doesn't have stays null. If that lookup fails, the rows still go out unnamed and uncached. KV-cached 1 h (nhl:shots:v3:{team}:{season}). |
GET | /goalie-shots?goalieId=&season=&gameType= | Shots one goalie faced, for the goalie popup's shot map. Same gameType rule as /player-shots. |
GET | /xg-trend?team=&season=&gameType= | A team's per-game 5v5 xGF% from game_xg, for the team sparkline. Same gameType rule as /player-shots (until 2026-09 its playoff games were mixed in). |
GET | /draft/pick-history?team= | Where a team's recent draft picks came from and went, from Supabase's draft_pick_history (eyewall-pipeline's draft_history.py: the NHL records API's draft data, each pick's teamPickHistory parsed into pick_chain, original owner first, drafting team last). Response: { team, sinceYear, made, tradedAway } over the last 5 drafts, newest first — made = picks this team used, tradedAway = this team's own original picks another team used (original_team=eq.X&team=neq.X). Each row: draft_year, round, pick_in_round, overall_pick, team, original_team, pick_chain, times_traded, player_id, player_name, position. team must be 2–3 letters (400 otherwise). KV-cached 6hr; a failed read returns empty lists and is not cached. |
GET | /playoff-odds?team=&season= | A team's simulated playoff odds from Supabase's playoff_odds / playoff_odds_game_impacts (eyewall-pipeline's nightly playoff_odds.py: the rest of the regular season simulated from team Elo ratings). Without season, the season of the team's most recent run. Response: { team, season, runDate, stale, latest, history, nextGames } — latest = that run's row (playoff_pct, division_pct, proj_points, points_p10/points_p90, current_points, games_played, games_remaining, elo_rating, sims, and change: { prev_run_date, prev_pct, delta, contributions: [{ game_id, home, away, winner, delta }], residual }, why it moved since the previous run — null on a season's first run); history = [{ run_date, playoff_pct }] for that season, oldest first; division = the team's whole division from that same run, so division_pct reads as a standing rather than a bare number: { name, rank, of, teams: [{ team, divisionPct, playoffPct, projPoints }] }, ranked by division odds (division membership comes from the cached NHL standings' divisionName; null when those are unavailable); nextGames = the team's odds under each result of the next game-day's games, [{ game_id, game_date, home, away, ifHomeWins, ifAwayWins, own, swing }], own games first, then up to 2 other games that swing its odds by ≥ 0.5 pts (summarizeNextGames(), src/playoffOdds.js); stale = no run for 3+ days (season over, or the nightly stopped). Before the season's first run latest is null. team (2–3 letters) and season (8 digits) are validated (400 otherwise). KV-cached 1hr; a failed read returns unavailable: true and is not cached, nor is an empty result. |
GET | /injury-impact?team=&season= | A team's season-to-date man-games and WAR lost to injury from Supabase's team_injury_impact (eyewall-pipeline's nightly injury_impact.py: a player on the day's injury report who didn't dress is a man-game lost, valued at his WAR per game). Without season, the team's most recent season row. Response: { team, season, impact, league } — impact = { season, team, games_played, man_games_lost, war_lost, players_injured, rank_man_games, rank_war_lost, players: [{ player_id, player_name, games, war_lost, last_date, status, injury_type }], updated_at } (ranks: 1 = most lost in the league); league = { teams, avgManGames, avgWarLost, avgGamesPlayed } over every team's row that season (summarizeInjuryLeague(), src/injuryImpact.js). impact is null before the 2026-27 regular season's first game (injury history starts 2026-09-12). team (2–3 letters) and season (8 digits) are validated (400 otherwise). KV-cached 1hr; a failed read returns unavailable: true and is not cached, nor is an empty result. |
GET | /scorecard | The public prediction scorecard from Supabase's prediction_scorecard (eyewall-pipeline's nightly prediction_scorecard.py): how the app's published predictions have done. Response: { models: { game_winner | starting_goalie | playoff_odds: { live, backtest } }, updatedAt } — live = graded predictions that were published beforehand (from 2026-27; the model's most recent live period, so a new season's row takes over), backtest = the model replayed on past seasons, labeled as such (summarizeScorecard(), src/scorecard.js). Each row: model, kind, period, status ('ok'|'pending'), n, accuracy, brier, log_loss, baseline {name, accuracy, brier}, calibration [{bucket, n, predicted, actual}], recent, note, updated_at. KV-cached 1hr; a failed read returns unavailable: true and is not cached, nor is an empty table. |
GET | /elo/ratings?league= | Every team's Elo rating from team_elo_ratings plus the home advantage: { ratings: { ABBR: rating }, homeAdvantage }. league=pwhl, ahl or echl (2026-10; the param used to be ignored, so league=pwhl answered NHL ratings) reads {league}_team_elo_ratings instead (eyewall-pipeline's hockeytech_elo.py), with ratings keyed by HockeyTech team_id and its own KV key; any other league is a 400 — the frontend computes each matchup's win probability with the exact formula /prediction/analyze (eloWinProb()) and eyewall-pipeline's win_probs.py (the morning log the public scorecard grades) use: P(home) = 1 / (1 + 10^((away − (home + homeAdvantage)) / 400)), no advantage at a neutral site. One call covers the game preview's win bar and every schedule card. KV-cached 1hr; a failed read returns unavailable: true and is not cached, nor is an empty table. /prediction/analyze now applies the same neutral-site rule (it used to give the home side the advantage at neutral sites too). |
GET | /probable-starters?game= | Both teams' goalies and the probability each starts one game, from Supabase's goalie_start_probs (eyewall-pipeline's nightly starting_goalie.py: for each team's next regular-season game once it's within 2 days — a model of the team's own pattern, since the NHL publishes no probable starters). Response: { gameId, gameDate, runDate, teams: { ABBR: [{ goalie_id, goalie_name, start_prob, factors }] } }, each team's goalies most likely first (summarizeStarters(), src/probableStarters.js); factors = { share_last10, started_last, back_to_back, days_rest, no_recent, injury_status }. teams is {} until the game is inside the 2-day window (or for a team the pipeline skipped as still carrying a training-camp roster). game must be a 10-digit NHL game id (400 otherwise). KV-cached 1hr; a failed read returns unavailable: true and is not cached, nor is an empty result. |
GET | /player-analytics?season= | Every skater's MoneyPuck analytics from Supabase's player_seasons: { rows, poRows, statsStale, statsSeason }. rows are regular-season rows with WAR: WAR, RAPM and rapm_toi_min (the ice time behind it), per-60 rates and pct_* percentiles. poRows are the same season's playoff rows: playoff WAR and RAPM (eyewall-pipeline's pooled playoff model, shrunk toward the regular season), rapm_toi_min, rates and the four box-score defensive stats. There are no playoff percentiles. When the season has no rows yet, both come from the season before, with statsStale: true. KV-cached 1hr. |
GET | /goalie-analytics?season= | Every goalie's GSAX, save % by danger/situation, QS% and percentiles from goalie_seasons: { rows, poRows, statsStale, statsSeason }. poRows are the playoff rows (GSAX, save %, QS%; no percentiles) from the same season as rows. Same whole-season fallback as /player-analytics. KV-cached 1hr. |
GET | /team-lines?team=&season=&gameType= | A team's most-used 5v5 forward lines and D pairs from Supabase's line_combinations (eyewall-pipeline's line_combinations.py), with TOI and xGF%. gameType 2 (regular season, default) or 3 (playoffs); 400 otherwise. The pipeline builds units per game type and never from preseason, so before a team's first regular-season game this is [] and the app shows /projected-lines. Each row's source is current, prior_season (carried over from last season to fill a slot) or regular_season (a playoff slot filled from the regular season). KV-cached 1hr per team, season and game type; a failed read is a 502 and isn't cached. |
GET | /projected-lines?team= | A team's projected forward lines and D pairs for its next game, from Supabase's projected_lines (eyewall-pipeline's nightly projected_lines.py: last game's pairings in-season, pooled preseason pairings before the team's first game; see that repo's docs/projected_lines_backtest_results.md for accuracy). Response: { team, basis: 'last_game' | 'preseason' | null, basisGameId, basisGames, generatedAt, lines, pairs }, where lines/pairs are ranked { rank, players: [{ id, name, pos, filled }] } with forwards ordered L-C-R, and filled marks a replacement for a player who was in the basis lineup (summarizeProjectedLines(), src/projectedLines.js). basis: null with empty lists means there's no projection yet. Different from /team-lines, which is the season's most-used units with xGF%. team must be 2–3 letters (400 otherwise). KV-cached 1hr; a failed read returns unavailable: true and is not cached, nor is an empty result. |
GET | /nhl/today | The scoreboard's games — today's when there are any, otherwise the next day that has some. Same normalized shape as /pwhl/today//ahl/today//echl/today (session102), sourced from the live NHL API rather than a Supabase table (NHL has no nhl_game_log-equivalent; the API already returns real team abbrevs/scores). Not score/now: that endpoint is not "today" out of season — on 2026-09-15 it served Sept 29's regular-season opener and skipped the Sept 19 preseason games entirely. The route asks for today's Eastern date (score/{date}) and, when that date is empty, walks forward via schedule/{date}'s gameWeek (which does include preseason) to the next date with games. Each game carries gameDate, startTimeUTC and gameType so the client can say "Today" only when it means it, plus, while live, period/periodType/clock/inIntermission, and endedIn (OT/SO) once final. broadcasts lists the game's TV networks ([{network, market, countryCode}], national first, then away, then home; from the NHL feed's tvBroadcasts). The clock is a snapshot, not a ticking one (60s KV TTL, matching poll()'s own cadence). |
GET | /special-teams?season=&gameType= | PP/PK unit rosters for every team that has them, for one game type (gameType 2 regular season, the default, or 3 playoffs; 400 otherwise). The pipeline never infers units from preseason games, so a team yet to play a regular-season game has none. as { ABBR: { PP: { 1: [ids], 2: [ids] }, PK: { … } } }, from Supabase's special_teams_units (eyewall-pipeline's special_teams.py infers them from shift + shot events). season optional, defaulting to the resolved current season — the shot map passes whichever season it is displaying, since it falls back to the last played one between the schedule being published and opening night. Returns {} rather than an error for a season with no rows, which is the normal state for a season that hasn't been played. KV-cached 4hr per season and game type (pp_units:{season}:{gameType}) |
GET | /health | Public Worker health check, deliberately thin (security audit 2026-10-13): {ok, timestamp, seasons: {nhl: {seasonId}, pwhl, ahl, echl: {seasonId, seasonType} | null}, cron: {nhl, pwhl, ahl, echl: {lastPollAt, lastOk} | null}}. lastOk is whether that league's last poll tick succeeded. seasons is what the resolvers have cached (an override first, seasons.js's readCachedSeasons()): /health never fetches upstream, so null means live resolution hasn't cached a season (it's failing, or the Worker just started). Subscriber counts, live game ids, lastError text and the health:ops:* reports moved to the owner-only /admin/health; nothing in the app read them here |
POST | /poll | Manual poll trigger (POLL_SECRET, see Sending POLL_SECRET) |
GET | /alerts/recent?teams=NHL:CAR,PWHL:MIN | Those teams' alerts from the last 72h, newest first (up to 50), whether or not anyone is subscribed — the app's notifications bell. 400 without a valid LEAGUE:ABBR. Cached 30s |
POST | /push/subscribe | Register a push subscription — Web Push body {endpoint, keys, teamAbbr, prefs}, or native iOS (2026-09) {platform: 'ios', token, teamAbbr, prefs}. Either may send teams: [{key: 'LEAGUE:ABBR', prefs}] instead (up to 20, NHL/PWHL/AHL/ECHL) to follow several teams, each with its own alert choices. Dedupes by token (case-insensitively; stored lowercase) for iOS, endpoint otherwise. Each subscribe also lowercases every stored iOS token and merges case-duplicates, keeping the most recently written entry — entries from before 2026-10-13 hold the uppercase hex Capacitor reports, which a re-subscribe used to duplicate instead of replace. Validated (security audit 2026-10-13; 400 otherwise): a JSON object; a Web Push endpoint must be https on a browser push service (PUSH_ENDPOINT_HOSTS in shared.js: fcm.googleapis.com, android.googleapis.com, updates.push.services.mozilla.com, *.push.services.mozilla.com, *.push.apple.com, *.notify.windows.com), at most 2048 characters; keys, when sent, base64url p256dh (80-100) and auth (16-32) — a subscription without keys is still taken and gets payloadless pushes; an iOS token is 64-200 hex characters (stored lowercase); teamAbbr a string naming LEAGUE:ABBR or a bare NHL abbr; prefs an object (boolean values kept, up to 32 keys, 2 KB). push:subs holds at most MAX_PUSH_SUBS (20,000): past it a new subscription is a 429 {error: 'Subscriber list full'}, while an existing one still updates. Rate-limited per IP (PUBLIC_WRITE_LIMITER). A subscription whose sends fail PUSH_FAILURE_LIMIT (5) broadcasts in a row is dropped (failures on the entry; a success resets it; only counted when another send on the same platform succeeded in that broadcast, so missing/rotated keys or an outage can't empty the list); a 410/404 still drops it at once |
POST | /live-activity/register | The iOS app's lock-screen Live Activity push token for one game, {gameId, token, league?} (token: 64-200 hex characters, stored lowercase; league is nhl (the default, for older builds), pwhl, ahl or echl), kept per game in la:tokens:{gameId} for the NHL and la:tokens:{league}:{gameId} for the others (8h), at most LA_MAX_TOKENS (200) per game: a full list refuses a new token with 429 {error: 'Live Activity token list full'} rather than dropping the oldest, so a flood can block new registrations but never evicts a device that registered earlier (a token already on the list still succeeds). Rate-limited per IP (PUBLIC_WRITE_LIMITER), and one IP may add at most LA_MAX_TOKENS_PER_IP (50 — venue Wi-Fi and carrier CGNAT share one IPv4 across many phones) new tokens per game (429 {error: 'Too many Live Activity tokens from this network'} past it; counted in la:ipcount:{league}:{gameId}:{hash} under a hash of the IP, never the IP). Each league's poll (poll(), pollPWHL(), pollAHL()/pollECHL()) then pushes the game's state to every registered activity whenever it changes (APNs liveactivity push type: score, period and last goal/penalty at priority 10, clock- or shots-only changes at 5; last state in la:last:[{league}:]{gameId}) and ends them with the final score, once. Uses the same APNS_* secrets as native push; the state's keys match GameActivityAttributes.swift in eyewall-analytics. For the PWHL/AHL/ECHL (liveActivity.js's hockeyTechLiveActivityState()): score, period (PeriodNameShort, normalised to 1st..3rd, OT, 2OT.., SO), clock (GameClock) and intermission from HockeyTech's scorebar, read fresh each tick (fetchScorebar(..., { fresh: true }), since 2026-10-19: through the 60 s KV copy the clock ran 2-3 minutes behind; the clock is still a once-a-minute snapshot, since the feed has no running flag to tick from). The intermission is shared.js's scorebarIntermissionState(), which also says which period it follows, because the feeds show it differently (live AHL/ECHL rows captured 2026-10-18): the ECHL never sets the Intermission flag and sits at 00:00 in the ended period; the AHL sets it (GameStatus 10, Intermission) minutes after the horn; and both put the next period up at 20:00 several minutes before the puck drops. So the flag, an intermission GameStatusString, a live game whose clock has run out (0:00/00:00/00:00:00) in a regulation or overtime period, or a live game showing period 2 or later at exactly 20:00 (20:00:00 too) is an intermission. The last is the intermission after the previous period, shown as that period at 00:00 ("2nd INT", not "3rd · 20:00"); period 1 at 20:00 is the opening faceoff. Never the shootout, never a final, and GameStatus 3 'Unofficial Final' counts as final (deriveGameStatus()), so the last period no longer reads as an intermission for the minutes before 4 'Final'; the final push and the game_log write go out at 3, once, with OT/SO from the play-by-play (endedInFromPlays(): 3's status text doesn't say); the last goal/penalty and shots on goal (count of shot events per shooterTeamId) from gameCenterPlayByPlay; strength is always null (no reliable power-play state in the feed) |
POST | /live-activity/start-token | "Follow my team's games" (iOS 17.2+): the app's ActivityKit push-to-start token for every team it follows, {token, teams, enabled, locale} with teams like ["nhl:CAR", "ahl:CHI", "echl:FLA", "pwhl:MIN"] (older builds send team: "CAR", read as ["nhl:CAR"]). Unknown teams are dropped (at most 20 are read); 400 if enabled and none is known, or the token isn't 64-200 hex characters. Rate-limited per IP (PUBLIC_WRITE_LIMITER); one IP may register at most 50 tokens a day that the Worker hasn't seen (la:ipcount:start:{hash}, 429 past it); a team's list holds at most 200 tokens, and a request that would add a new token to a full list is refused whole (429 {error: 'Live Activity token list full'}, nothing changed) — a token already on the list, moving off a team, and enabled: false still work. Kept in la:start:{TEAM} (NHL, unchanged) / la:start:{league}:{TEAM} as {token, locale} (90 days, re-sent on every app launch); la:startteams:{token} records the token's teams, so a team no longer listed loses it (the pre-2026-10 single-team la:startteam:{token} is cleaned up the same way), and enabled: false removes it from all. Answers {ok, teams, team} (team: the first NHL team, or null, for older builds). When a followed team's game is live, the league's poll sends each token one APNs event: start push per game (tracked in la:started:[{league}:]{gameId}; a token following both teams gets one) — but only after reading the token's la:startteams record (or the legacy la:startteam): a token on a team's list whose record is missing or doesn't name that team is skipped and taken off the list instead (2026-10-13: a stale la:start:PIT entry started a PIT @ CHI activity for a device that followed CAR and PWHL MTL; the list is an index, the record decides), carrying the GameActivityAttributes fields {gameId, homeAbbr, awayAbbr, homeColor, awayColor, followAbbr, league}, each team's display colour from LA_TEAM_COLORS (kept in sync with eyewall-analytics' teamConfig.js, pwhlConfig.js, ahlConfig.js, echlConfig.js), plus an en/fr alert. The Live Activity appears even if the app is closed. The app then reports the new activity's update token to /live-activity/register, and updates carry on as above |
POST | /push/unsubscribe | Remove a push subscription — body {endpoint} (Web Push) or {token} (native iOS, case-insensitive). One of them must be a non-empty string (400 otherwise, and on bad JSON); only the matching entry is removed. {} used to delete every iOS subscription (security audit 2026-10-13). Rate-limited per IP (PUBLIC_WRITE_LIMITER) |
POST | /atom/ingest | Ingest team-blog RSS/Atom articles from GH Actions (auto-detects real Atom vs. plain RSS 2.0 per source) |
POST | /moneypuck/ingest | Ingest MoneyPuck data from GH Actions |
GET | /moneypuck/refresh | Refresh MoneyPuck for one team |
GET | /moneypuck/refresh/all | Refresh MoneyPuck for all teams |
GET | /pp-units/refresh?season=&gameType=&secret= | Refresh PP/PK unit data — season optional, defaults to the resolved current season. The reply points at /special-teams?season=&gameType= to check the result (it used to point at /cache/pp_units:…, which the /cache/ allow-list doesn't serve) |
POST | /summary/generate | Generate AI game summary |
POST | /summary/narrative?gameId=&period=&carAbbr= | AI period/game narrative (cached per team perspective, narrative:{period}:{gameId}:{carAbbr}, 30 days). carAbbr must be an NHL team (TEAM_CONFIGS), checked before the cache key is built; period is game or a period number. Since the security audit 2026-10-13 the game itself is read server-side — pbp:{gameId} from KV, else (or when that copy doesn't show the period over yet) the NHL's play-by-play — and gives the matchup (carAbbr must be one of its teams, 400 otherwise; the opponent and playoff flag come from it, not the body). A narrative is only written once it's over: 409 {error: 'Game not final'} / 'Period not complete' (a period is over at its period-end play or once play has moved past it), 409 'Game state unavailable' when neither source has the game. Body: numbers must be finite (else left out), goals are capped at 20 with a clock, a strength from EV/PP/SH/EN/PS and a scorer name that must be on the game's roster (rosterSpots, else unknown), goalie names likewise, periodLabel letters/digits only (safeName()); body team abbreviations, when sent, must be 2-4 letters (400) and are otherwise unused. Since 2026-10-19 a period prompt also carries the body's carScore/oppScore — the score after the period, from the app's summary — as Score after this period, and says what the period means for the result (P1–P2: the game goes on; P3 or later, tied: overtime; score not sent: don't assume one): P3 of BOS @ CAR 2025021237 (5-5 after it, CAR 6-5 in OT) had read "Boston ... secured the win" from the period's 0-1 alone. Both answers are cached as one plain paragraph (plainNarrative(), shared.js): a **Summary**/**Caption** heading, label, bullet or bold marker the model adds is dropped, the caption keeping only its Caption section (several cached game captions carried both headings onto the shareable card before 2026-10-20; the card prompt no longer opens with the game-summary ask that invited them), and an answer that is nothing but headings 502s like an empty one |
POST | /prediction/analyze | Generate pre-game prediction. force=1 (skip the cache and regenerate) needs POLL_SECRET (X-Poll-Secret header; see Sending POLL_SECRET) since 2026-10; without it the route answers 401 |
GET | /draft/rankings | Draft rankings from Supabase |
GET | /draft/picks | Draft picks from Supabase |
GET | /draft/order | Draft pick order |
POST | /draft/analyze | AI draft pick analysis |
GET | /team-seasons/compare?team=&seasons= | Box-score fields only (wins/losses/OTL/points/goals-for-against/PP%/PK%) for one team across a comma-separated season list. Backs the season-over-season team comparison feature (Session 64). Missing seasons for that team are simply absent from the response — no placeholder rows. |
GET | /team-seasons/compare-teams?teams=,&season= | Box-score fields only, for exactly two teams at one shared season. Backs Team vs Team comparison Mode 1 (Session 86) — the two-team analog of /team-seasons/compare's two-season shape. A team missing a row for that season (e.g. didn't exist yet) is simply absent from the response. |
GET | /team-seasons/head-to-head?teams=, | All-time head-to-head between two teams across every season on record — Team vs Team Mode 2 (Session 88). Filters game_log for one team's own rows against the named opponent (no season/game_type filter — includes playoff meetings), then computes allTimeRecord/recentWindow/currentStreak/isThinSample server-side via buildHeadToHeadPayload (shared.js), so there's one definition of the derived insights instead of duplicating the math per league. |
POST | /team-seasons/head-to-head/narrative | AI narrative layer on top of the head-to-head stats above (Session 90). Client posts the payload it already fetched from /team-seasons/head-to-head — this route doesn't refetch/recompute anything. teamA/teamB must be different NHL teams (400 otherwise, before the cache); the names in the prompt come from TEAM_CONFIGS, never the body's teamADisplay/teamBDisplay; the counts must be whole numbers within totalMeetings (at most 1,000) and currentStreak.holder A or B (h2hNarrativeStats(), 400 otherwise). Cached in KV per team pair. Returns { narrative: null } without calling the model for zero-meeting pairs; flags a thin-sample guardrail in the prompt when isThinSample is true so the model doesn't overstate a 2-4 game sample as a trend. |
GET | /team-seasons?season= | Every team's regular-season row from team_seasons: xgf_pct, roster_war_score, games_played, magic_number/tragic_number/clinched/eliminated (the pipeline's nightly playoff_race.py estimate — the live clinch indicator comes from /cache/standings), hits, penalties. KV-cached 1hr (nhl:team-seasons:{season}) |
GET | /game-log?team=&season=&gameType=&limit= | A team's game_log rows for a season, oldest first (score, opponent, home, scored first, PP/PK). gameType 1/2/3 filters, omitted = every type. KV-cached 1hr |
GET | /game-xg?gameId= | One game's 5v5 xGF/xGA/xGF% per team from game_xg. KV-cached 30min |
GET | /power-rankings?team=&season=&limit= | NHL power-ranking narratives from power_rankings_narratives (narrative, rank, prior_rank, generated_date), newest first; limit default 28, max 100 (1 = the latest narrative). KV-cached 1hr. The PWHL/AHL/ECHL equivalents are /{league}/power-rankings |
GET | /game-predictions?gameId=&locale= | The pipeline's pre-game matchup_text/prediction_text from game_predictions, per locale. KV-cached 30min |
GET | /game-summary?gameId=&team=&locale= | The pipeline's AI game summary for one team's perspective (game_summaries). KV-cached 30min |
GET | /player-scouting?playerId=&season=&locale= / /player-results-vs-process?… | One player's AI scouting / results-vs-process blurb (player_scouting / player_narratives). KV-cached 1hr |
GET | /team-skaters?team=&season=&gameType= | A team's skater box-score season lines from player_seasons (gameType default 2), points first. KV-cached 1hr |
GET | /players-list | Every NHL player's {id, name, position} (paged past the 1,000-row cap). KV-cached 6hr |
GET | /player/landing?id= | NHL API player/{id}/landing proxy (the NHL API has no CORS). KV-cached 1hr (player:landing:{id}) |
POST | /news/refresh?team= | Refetch one team's news now (POLL_SECRET) |
POST | /push/test | Broadcast a test push to every subscriber (POLL_SECRET) |
GET | /social/test?post= | Preview a sample CAR game-recap X post (fixed test data); post=1 posts it (POLL_SECRET; a no-op while the X keys are unset, see Secrets) |
pwhl_playersgoalspenaltyShotshockeytechGameDetail.jsgoals[{ period, time, team_id, scorer_id, scorer_name, assist_ids, plus_player_ids, minus_player_ids, strength }]gameSummary{league}_goal_on_icestrengthEVPPSHENPS[]penaltyShots[{ period, time, team_id, shooter_id, shooter_name, goalie_id, result }]{league}_penalty_shotsresultgoalmisspwhl:game-box:v3:GET | /pwhl/player-game-log?playerId=&seasonId= | One player's game-by-game box score rows for a season — player Compare tab trend charts (Session 70) |
GET | /pwhl/lastgame?teamId=&season= | Most recent completed game |
GET | /pwhl/pbp?gameId= | Completed game PBP + shot events. Player names (and a missing team_id) from the game's gameSummary lineup, then pwhl_players by id |
GET | /pwhl/salaries?teamId=&season= | Salary data |
GET | /pwhl/league-players?season= | All teams' skaters + goalies |
GET | /pwhl/player/percentiles?id=&season=&seasonType= / /pwhl/goalie/percentiles?id=&season=&seasonType= | Precomputed percentiles from pwhl_player_seasons / pwhl_goalie_seasons (eyewall-pipeline's pwhl_percentiles.py / pwhl_goalie_percentiles.py). A season id is read with its own type (9 → playoffs; since 2026-10-08, before which every id was read as regular), seasonType overriding; omitted, the latest regular season. No row is 200 with null percentiles. KV-cached 1hr (pwhl:{player,goalie}:percentiles:{id}:{season|latest}:{type}) |
GET | /pwhl/player/landing?id=&season= | Single player's identity + one season's stat line, merged (pwhl_players + pwhl_player_seasons/pwhl_goalie_seasons). Powers PWHLPlayerPopup's self-fetch-by-id. season pins the stat line to that season_id, regular or playoffs (a season_id belongs to one season type, so no type filter is applied); omitted, falls back to the most recent regular-season row. |
GET | /pwhl/player/career?id= | Career Regular Season / Playoffs totals, live proxy of HockeyTech's view=player server-computed careerStats Total rows (no Supabase, no aggregation on this side). playoffs is null if the player hasn't made the playoffs yet. Also carries bioPoints (plain-text career-highlight bullets, HTML stripped server-side), photo (the is_primary entry from the player's photo gallery), draft (null unless HockeyTech's own display_drafts flag is true and a row exists — null for most players tested so far), and recentGames (last 5 games, most-recent-first; field set differs by position). |
GET | /pwhl/transactions?season= | League-wide signings/moves feed, live proxy of HockeyTech's view=transactions. Genuinely new fetch — not wired into any pipeline table. Returns {transactions: [{date, player, team, type, action, from}]}, player includes position inline (e.g. "Neena Brick (F)"). Not paginated — serves HockeyTech's default 50-row page as-is, the num_results section is ignored. |
GET | /pwhl/player-shots?playerId=&season= | Player shot heat map data |
GET | /pwhl/goalie-shots?goalieId=&season= | Shots faced by a specific goalie, for the goalie heat map (Session 100) — mirrors /pwhl/player-shots's query/normalisation shape against pwhl_shot_events.goalie_id, excludes blocked_shot (never reaches the goalie) |
GET | /pwhl/league-averages?season= | League averages for the PWHL Team page's Advanced tab (added 2026-10, replacing a hardcoded table of "2025-26 approximations"): goals, shots for/against per game, SH%, SV%, PDO, PP% and PK%, regular season. Summed over every team's real totals and computed the same way the tab computes the team's own number -- shots and SH% from pwhl_player_seasons, shots against and SV% from pwhl_goalie_seasons, the rest from pwhl_team_seasons; PP%/PK% weighted by chances. A part with no data is null, never a default; 404 when the season has no team rows. 1hr cache (pwhl:league-averages:{season}). |
GET | /pwhl/league-goalie-shots?season= | Every shot on goal (goals and saves) any PWHL goalie faced in the season, as compact [x, y, isGoal] rows (added 2026-10) -- what the app needs to work out the league's save % in each NHL shot area (react-hockey-rink's shotArea) and color a goalie's area map against it. Same coordinates as /pwhl/goalie-shots (folded to +x); empty nets (no goalie_id) left out. ~6,700 rows a season. 6hr cache (pwhl:league-gshots:{season}). |
GET | /pwhl/today?season= | Today's games, or the next day that has games within the coming week (today plus 6 days, TODAY_LOOKAHEAD_DAYS in shared.js: the window /nhl/today reads; past it the list is empty — on 2026-10-06 it served a lone preseason game 47 days out as "today"), with live status. For the current season (or no season) it reads every season with games around now — the current one, its preseason, HockeyTech's current_season_id, and the next season and its preseason — so the 2026-27 preseason (season 10, Nov 22–30) still shows after season 11 becomes current; any other season reads that season only. Each game carries seasonId, gameDate, HockeyTech's own statusDetail text (a start time before puck drop), endedIn (OT/SO) once final, and the live fields /ahl/today documents (period, periodType, periodLabel, clock, inIntermission, since 2026-10-17). period is the scorebar's, falling back to pwhl_game_log's period column (which lags) when the scorebar gives none. |
GET | /pwhl/live/:gameId | Live PBP + normalized events: the /ahl/live payload (gameId, team ids, homeScore/awayScore, gameStatus, events, which the app's hockeyTechLiveStore reads for all three leagues) plus PWHL's blocked_shot/faceoff/hit events, goalieStats and faceoffStats. Since 2026-10-08 it also lists penaltyshot and shootout events (team from shooterTeam/shooter_team, shootout in period 7 = SO), a final's score is the game row's (which counts the shootout winner's goal: PWHL 326 used to read 1–1), and the game row carries game_id so the scorebar's live status lays over it |
GET | /pwhl/news | PWHL news feed |
POST | /pwhl/news/ingest | Ingest articles from GH Actions |
POST | /pwhl/news/bust | Invalidate news KV cache |
POST | /pwhl/scout | AI scouting report for a player |
POST | /pwhl/cache/bust?secret=&teamId=&season= | Invalidate one team's KV caches (players/shots/schedule/lastgame) for a given season |
GET | /pwhl/summary?gameId= | Game summary (goals, MVPs, team stats, venue, officials, head coaches, goalieLog — each team's goalie stints, which the AI summaries name the goalie in net from, and endedIn — OT/SO/null once final) from HockeyTech |
POST | /pwhl/summary/narrative?gameId=&period=&carAbbr= | AI period/game narrative (cached per team perspective, 24 h). gameId required; carAbbr must be a PWHL team (PWHL_TEAM_CODES) and one of the game's; period is game or a period number. The game is read server-side (pwhl_game_log's row with the pwhl:scorebar status, period and intermission over it): 409 until the game is final / the period is over (a later period, or the game is in that period's intermission by scorebarIntermissionState(), the Live Activity's own signal: the Intermission flag, an intermission GameStatusString, the period's clock run out, or the next period put up at 20:00 -- which counts as the intermission after the previous period, not as the next period having started), 409 when the game isn't in pwhl_game_log. Team names come from PWHL_TEAM_NAMES (the body's carName/oppName are ignored), and every other body field is a finite number, a period number, a strength enum or a safeName()-cleaned name (goals capped at 20) |
GET | /pwhl/preview?gameId= | Pre-game preview for an upcoming game — season series, head-to-head, streaks, team-scoped leading scorers, special teams (Session 51, live HockeyTech gameCenterPreview passthrough). A team block HockeyTech sends empty (no team id: a game between teams with no season rows, e.g. the preseason) is null, and a 0-0-0-0 streak/overall/last-10 record is null (2026-10; it used to read as a real record) |
GET | /pwhl/prediction?gameId=&force= | Win probability + AI narrative for an upcoming game (Session 51). Win % is Elo since 2026-09 (pwhl_team_elo_ratings, written by eyewall-pipeline's hockeytech_elo.py; +35 home advantage, a team with no rating at 1500), replacing the point-split heuristic, which backtested worse than a coin flip and gave the home team 0% whenever stats tied. Response carries winModel: 'elo'; KV key is pwhl:prediction:elo:{gameId}. Corsi/PP% still feed the AI prompt. corsiForPct prefers 5v5-filtered shot-attempt share, falling back to all-situations if the 5v5 column isn't populated yet (Session 53, same preference order as /prediction/analyze) — check corsiCaveat for which one a given response used. Early-season stats are blended with last regular season's and a missing stat is "not available" (2026-09, see Prediction win probability); expHome/expAway can be null |
GET | /pwhl/team-seasons/compare?teamId=&seasons= | Box-score fields only (gp/wins/losses/OTL/points/goals-for-against/PP%/PK%) for one team across a comma-separated season_id list. PWHL analog of /team-seasons/compare (Session 64). Missing seasons for that team are simply absent from the response — no placeholder rows. |
GET | /pwhl/team-seasons/compare-teams?teamIds=,&season= | Box-score fields only, for exactly two team_ids at one shared season_id. PWHL analog of /team-seasons/compare-teams (Session 86). A team missing a row for that season (e.g. a 2026-27 expansion team with no prior season) is simply absent from the response. |
GET | /pwhl/team-seasons/head-to-head?teamIds=, | All-time head-to-head between two teams across every season on record — PWHL analog of /team-seasons/head-to-head (Session 88). pwhl_game_log is one row per game with both teams in columns, so this uses an OR-of-AND home/away filter (no season_id filter) instead of NHL's simple two-sided filter, then shares the same buildHeadToHeadPayload derived-insight computation. |
POST | /pwhl/team-seasons/head-to-head/narrative | AI narrative layer on top of the head-to-head stats above (Session 90) — PWHL analog of /team-seasons/head-to-head/narrative. teamA/teamB must be PWHL team ids; names come from PWHL_TEAM_NAMES (kept in sync with eyewall-analytics' pwhlConfig.js), not the body; the counts are checked as for the NHL route. |
GET | /pwhl/playoff-odds?teamId=&season= | Playoff odds (contract C10) from eyewall-pipeline's hockeytech_playoff_odds.py (pwhl_playoff_odds, one row per team per nightly run): { latest: {season_id, team_id, run_date, make_playoffs_pct, win_division_pct, proj_points_p10/p50/p90, current_points, games_played, games_remaining, sims, format}, history: [{run_date, make_playoffs_pct, proj_points_p50}] (that season, oldest first), stale }; stale = latest run more than 48 h old. make_playoffs_pct/win_division_pct may be null (format not verified) and are passed through. Without season, the season of the team's latest run. 1 h KV (pwhl:playoff-odds:{teamId}:{season|latest}). Table missing or no rows: { latest: null, history: [] }; a failed read adds unavailable: true; neither is cached, never a 5xx (leagueOddsRankings.js). games_played (eyewall-pipeline#217) is null, and the answer not KV-cached, until the owner's migration adds the column: PostgREST's 400 for it is retried once with the older column list |
GET | /pwhl/power-rankings?teamId=&limit=&season=&locale= | Power rankings (contract C12) from hockeytech_power_rankings.py: { latest: [{team_id, rank, prior_rank, score, components}] (latest run, by rank, limitdefault and max 100), narrative: {text, run_date} | null (the team's EyeWall AI narrative from that run inlocale, pwhl_power_rankings_narratives), history: [{run_date, rank}] (the team's last 28 runs, oldest first) }; no teamId → narrative null, history []. 1 h KV (pwhl:power-rankings:{teamId|all}:{season|latest}:{limit}[:fr]). Table missing or no rows: { latest: [], narrative: null, history: [] }, uncached; a failed read adds unavailable: true |
GET | /ahl/roster?teamId= | Bare roster (name + jersey) for name resolution (shot-map tooltips, etc.) |
GET | /ahl/players?teamId=&season= | Roster + skater/goalie season stats, name-enriched, jersey-sorted roster list for the Roster tab. Every skater and goalie who played for the team that season (no row cap), named from ahl_players by id. The Roster tab list leaves out players the pipeline marked off the team's roster (on_roster=not.is.false, sbRosterRows(); reads unfiltered until eyewall-pipeline's docs/sql/2026-10-06_on_roster.sql has added the column) |
GET | /ahl/league-players?season= | All teams' skater + goalie season stats (Leaders tab) One row per player: a traded player's rows (one per team once eyewall-pipeline#190 lands, one league-wide row before) are combined — counting stats summed, SV%/GAA recomputed (GAA from summed goals against and minutes; toi comes as "1648:49" or whole minutes "1294", and until 2026-10-08 the latter didn't parse, leaving every traded goalie's GAA null), teams with each team's part (hockeytechSeasonRows.js). Reads every row, paged past Supabase's 1,000-row cap; returns the top 600 skaters by points and every goalie. |
GET | /ahl/shots?teamId=&season= | Shot events for team heat map. Only shot/goal event_type rows exist — no blocked_shot in this data source |
GET | /ahl/game-shots?gameId= | Both teams' shot events in one game, in game order, for the shot map's single-game view (/ahl/shots holds one team's shots only) |
GET | /ahl/team-season-summary?teamId=&season= | Season-aggregate SOG (car vs. opp) + PP%/PK% for the Shot Map's "All N" summary card. Deliberately no hits/blocked/faceoff/penalties section, unlike /pwhl/team-season-summary — no ahl_pbp_events table and no blocked_shot event type exist for this league at all; don't fabricate zeros for these fields |
GET | /ahl/player/landing?id=&season= | Player-popup self-fetch: identity + one season's stat line, merged (ahl_players + ahl_player_seasons/ahl_goalie_seasons). Supabase-only, no HockeyTech call needed. season can be a regular or playoff season_id; omitted, the most recent regular season A traded player's rows for the season are combined the same way as /ahl/league-players (teams lists each team's part). |
GET | /ahl/player/career?id= | Career Regular Season/Playoffs totals, live proxy of HockeyTech's view=player server-computed careerStats Total rows — confirmed live to use the identical shape as /pwhl/player/career, reuses shared.js's extractCareerTotal/extractRows/extractBioPoints/extractPhoto unmodified. No ?season= param — career totals are season-independent |
GET | /ahl/player/percentiles?id=&season=&seasonType= | Skater percentiles (goals, 1st assists, penalty discipline, finishing) plus xg_for/finishing, from ahl_player_percentiles (eyewall-pipeline's hockeytech_percentiles.py; xG from ahl_player_xg when the percentile row doesn't carry it). Same shape as /pwhl/player/percentiles plus rateBasis: 'perGP' (AHL box scores have no TOI, so the rates ranked are per game played; the row's rate_basis_per_gp) or 'per60'. playerId= works as an alias of id=. season alone picks the row (regular or playoff id); omitted, the latest regular season. A traded player gets his row for the team he played most games for. No row, or the table not created yet (PostgREST 404/400), is 200 with null percentiles; the missing-table answer isn't KV-cached (max-age=300). KV-cached 1hr |
GET | /ahl/goalie/percentiles?id=&season=&seasonType= | Goalie percentiles (GSAX, GSAX per game, 5-on-5/high/medium-danger/PK SV%) from ahl_goalie_percentiles (hockeytech_goalie_percentiles.py). Same shape as /pwhl/goalie/percentiles plus rateBasis; gsax60 holds GSAX per game played when it's 'perGP' (label GSAX/GP). Same season, missing-table and caching rules as /ahl/player/percentiles |
GET | /ahl/player-shots?playerId=&season= | Single skater's shot heat map data. No /ahl/goalie-shots equivalent — AHL's PBP goal events carry goalie_id: null (not carried on the goal event itself, a real structural difference from PWHL's feed), so a goalie heat map would silently under-count every goal allowed |
GET | /ahl/lastgame?teamId=&season= | Most recent completed game, opponent abbr resolved. No OT/shootout fields (ahl_game_log has no such columns) |
GET | /ahl/summary?gameId= | Game summary (goals, MVPs, officials, coaches, venue) from HockeyTech's gameSummary view — mirrors /pwhl/summary except homeTeamStats/visitingTeamStats strip hits/faceoffAttempts/faceoffWins/faceoffWinPercentage, which read 0 regardless of the real game (confirmed at the per-player level too — not charted, not just a PBP omission) |
GET | /ahl/preview?gameId= | Pre-game preview for an upcoming game — raw passthrough of HockeyTech's gameCenterPreview view, no server-side reshaping (frontend does its own field reading, same as /pwhl/preview) |
GET | /ahl/game-box?gameId= | Per-player box score (skaters + goalies) for a completed game, from ahl_skater_game_box/ahl_goalie_game_box. No hits/faceoff/blocked-shots/skater-TOI columns at all. player_name from the game's own gameSummary lineup, then ahl_players by id Also goals and penaltyShots (2026-10, hockeytechGameDetail.js): goals is [{ period, time, team_id, scorer_id, scorer_name, assist_ids, plus_player_ids, minus_player_ids, strength }] — every goal in the game's gameSummary, in order, with the skaters on the ice from {league}_goal_on_ice and strength EV/PP/SH/EN/PS — and [] until that table has rows for the game (the AHL/ECHL tables arrive with eyewall-pipeline's goal-on-ice migration; a missing table or failed read never fails the box score). penaltyShots is [{ period, time, team_id, shooter_id, shooter_name, goalie_id, result }] from {league}_penalty_shots; result is goal or miss — the feed records only whether it scored, never a save versus a miss. KV key ahl:gamebox:v2:. |
GET | /ahl/player-game-log?playerId=&season= | One player's game-by-game box score rows for a season, oldest first (skaters[]/goalies[], whichever table has the player). Data for the player popup's Compare-tab trend chart — same shape as /pwhl/player-game-log, but note the param is season=, not seasonId= |
GET | /ahl/prediction?gameId=&force= | Win probability + AI narrative for an upcoming game. Win % is Elo (2026-09): ahl_team_elo_ratings (eyewall-pipeline's hockeytech_elo.py) with +35 home advantage, a team with no rating at 1500 -- replacing the point-split heuristic, which backtested worse than a coin flip and gave the home team 0% whenever stats tied (docs/hockeytech_elo_backtest_results.md in eyewall-pipeline). Response carries winModel: 'elo'. The AI prompt's stats are blended with last regular season's early on and never print a missing stat as 0 (2026-09, same as /pwhl/prediction); expHome/expAway can be null |
GET | /ahl/team-seasons/compare?teamId=&seasons= | Box-score fields only for one team across a comma-separated season_id list. AHL analog of /pwhl/team-seasons/compare |
GET | /ahl/team-seasons/compare-teams?teamIds=,&season= | Box-score fields only, for exactly two team_ids at one shared season_id. AHL analog of /pwhl/team-seasons/compare-teams |
GET | /ahl/team-seasons/head-to-head?teamIds=, | All-time head-to-head between two teams across every season on record — same buildHeadToHeadPayload (shared.js) derived-insight computation as NHL/PWHL |
POST | /ahl/team-seasons/head-to-head/narrative | AI narrative layer on top of the head-to-head stats above. teamA/teamB must be AHL team ids (AHL_TEAM_CODES); names come from AHL_TEAM_NAMES (kept in sync with eyewall-analytics' ahlConfig.js), not the body; the counts are checked as for the NHL route |
POST | /ahl/summary/narrative?gameId=&period=&teamId=&locale= | EyeWall AI period/final narrative (contract C8, the AHL/ECHL analog of /pwhl/summary/narrative) -> { narrative, cardNarrative }. period is 1-3, OT, 2OT.. (a period number 4+ is filed under its label) or game; gameId, teamId and period are required (400 otherwise, and for the shootout). The body is the stats the client built from /live + /summary: carAbbr, oppAbbr, carName, oppName, periodLabel, carSOG, oppSOG, carGoals, oppGoals, optional carHDCF/oppHDCF, penaltyCount, carPenaltyCount, goalieNames[], goals[{isCar, scorerName, time, period, strength}], and for game bestPeriod/worstPeriod {period, carSOG, oppSOG} (ranked by shots on goal: no Corsi in these leagues). A stat not sent is left out of the prompt. teamId must be one of the league's teams and one of the game's; the game is read server-side ({league}_game_log's row, or the scorebar's for a game not in it, with the {league}:scorebar status, period, clock and intermission over it): 409 until the game is final / the period is over (a later period, or that period's intermission by scorebarIntermissionState(), as /pwhl/summary/narrative), 409 when there's no row. Team names come from cfg.teamNames (AHL_TEAM_NAMES/ECHL_TEAM_NAMES), never the body's carAbbr/oppAbbr/carName/oppName; every other field is a finite number, a period number, a strength enum or a safeName()-cleaned name (goals capped at 20). Cached 30 days per game, period, team and language (ahl:narrative:{period}:{gameId}:{teamId}[:fr]), read back by the app through /cache/; rate-limited (AI_ROUTE_LIMITER) only on a cache miss; locale=fr adds FRENCH_INSTRUCTION |
GET | /ahl/news | AHL news feed (3 sources — see AHL & ECHL) |
POST | /ahl/news/ingest | Ingest articles from GH Actions (nightly ahl_news.py) |
POST | /ahl/news/bust | Invalidate news KV cache |
GET | /ahl/today?season= | Today's games, or the next day that has games, with live status. Each game carries gameDate, HockeyTech's own statusDetail text, and endedIn (OT/SO) once final, read from the scorebar's GameStatusStringLong (game_state says plain "Final" either way). While live (2026-10-17), from ahl:scorebar, which the poller refreshes from HockeyTech every minute (a snapshot, not a ticking clock; the game log has no period or clock columns): period (1–3, 4 for the first OT, 5 for the second..; null in the shootout), periodType (REG/OT/SO), periodLabel (1st..3rd, OT, 2OT.., SO), clock (GameClock, e.g. 07:58) and inIntermission (scorebarIntermission(), the Live Activity's signal; in an intermission the period, label and clock are those of the period it follows, 00:00 once the scorebar has put the next period up at 20:00) — null for any the scorebar doesn't give, never a placeholder; otherwise nulls and inIntermission: false, as /nhl/today. Reads season and every later regular/playoff/preseason season, and when ahl_game_log has no game today, HockeyTech's scorebar for the next 6 days (its first game day wins if sooner) — so a season game_log doesn't carry yet still shows. Both look at most 6 days ahead (TODAY_LOOKAHEAD_DAYS, /pwhl/today's window; since 2026-10-08 — game_log had no cap before), so past that the list is empty. |
GET | /ahl/live/:gameId | Live PBP + normalized events. Confirmed event types: goal, shot, penalty, goalie_change, penaltyshot, plus shootout (team from shooterTeam, period 7 = SO). Once the game is final, homeScore/awayScore are the game row's (game_log / scorebar), which count the shootout winner's goal. A game game_log doesn't have (the ECHL preseason is never ingested; a mid-season addition waits for the nightly) takes its teams, score and status from today's scorebar, the read /today serves it from (2026-10; before that it answered null teams, pre and 0–0 while live). No hit/faceoff/blocked_shot branches — confirmed absent from AHL's PBP entirely. Deliberately does not fetch gameSummary in parallel the way PWHL's live route does (no faceoff data to merge; goal events already carry assists/properties directly) |
GET | /ahl/playoff-odds?teamId=&season= | Playoff odds (contract C10) from eyewall-pipeline's hockeytech_playoff_odds.py (ahl_playoff_odds, one row per team per nightly run): { latest: {season_id, team_id, run_date, make_playoffs_pct, win_division_pct, proj_points_p10/p50/p90, current_points, games_played, games_remaining, sims, format}, history: [{run_date, make_playoffs_pct, proj_points_p50}] (that season, oldest first), stale }; stale = latest run more than 48 h old. make_playoffs_pct/win_division_pct may be null (format not verified) and are passed through. Without season, the season of the team's latest run. 1 h KV (ahl:playoff-odds:{teamId}:{season|latest}). Table missing or no rows: { latest: null, history: [] }; a failed read adds unavailable: true; neither is cached, never a 5xx (leagueOddsRankings.js). games_played (eyewall-pipeline#217) is null, and the answer not KV-cached, until the owner's migration adds the column: PostgREST's 400 for it is retried once with the older column list |
GET | /ahl/power-rankings?teamId=&limit=&season=&locale= | Power rankings (contract C12) from hockeytech_power_rankings.py: { latest: [{team_id, rank, prior_rank, score, components}] (latest run, by rank, limitdefault and max 100), narrative: {text, run_date} | null (the team's EyeWall AI narrative from that run inlocale, ahl_power_rankings_narratives), history: [{run_date, rank}] (the team's last 28 runs, oldest first) }; no teamId → narrative null, history []. 1 h KV (ahl:power-rankings:{teamId|all}:{season|latest}:{limit}[:fr]). Table missing or no rows: { latest: [], narrative: null, history: [] }, uncached; a failed read adds unavailable: true |
Player-popup self-fetch: identity + one season's stat line (regular or playoff season_id). Added 2026-08-30, same as /ahl/player/landing Traded players combined, same as /ahl/player/landing. |
GET | /echl/player/career?id= | Career Regular Season/Playoffs totals, live HockeyTech view=player proxy — reuses the same generic shared.js parsers as AHL/PWHL, confirmed identical shape |
GET | /echl/player/percentiles?id=&season=&seasonType= | Skater percentiles from echl_player_percentiles, same shape and rules as /ahl/player/percentiles (rateBasis: 'perGP') |
GET | /echl/goalie/percentiles?id=&season=&seasonType= | Goalie percentiles from echl_goalie_percentiles, same shape and rules as /ahl/goalie/percentiles |
GET | /echl/player-shots?playerId=&season= | Single skater's shot heat map data. No /echl/goalie-shots equivalent — same goalie_id: null structural gap confirmed live for ECHL too |
GET | /echl/lastgame?teamId=&season= | Most recent completed game, opponent abbr resolved. Added 2026-08-30 |
GET | /echl/summary?gameId= | Game summary from HockeyTech — confirmed byte-identical shape to AHL's gameSummary, same hits/faceoff-field stripping |
GET | /echl/preview?gameId= | Pre-game preview, raw HockeyTech gameCenterPreview passthrough — confirmed identical top-level shape to AHL's |
GET | /echl/game-box?gameId= | Per-player box score, from echl_skater_game_box/echl_goalie_game_box (already populated for both seasons from the foundation pass — no new pipeline work needed for this route) goals/penaltyShots as on /ahl/game-box. |
GET | /echl/player-game-log?playerId=&season= | One player's game-by-game box score rows for a season, same shape as /ahl/player-game-log. Added 2026-09 with the hockeytech.js merge |
GET | /echl/prediction?gameId=&force= | Win probability + AI narrative — Elo from echl_team_elo_ratings, same as /ahl/prediction |
GET | /echl/team-seasons/compare?teamId=&seasons= | Box-score fields only across a comma-separated season_id list. ECHL analog of /ahl/team-seasons/compare |
GET | /echl/team-seasons/compare-teams?teamIds=,&season= | Box-score fields only for exactly two team_ids at one shared season_id |
GET | /echl/team-seasons/head-to-head?teamIds=, | All-time head-to-head across every season on record |
POST | /echl/team-seasons/head-to-head/narrative | AI narrative layer on the head-to-head stats above. teamA/teamB must be ECHL team ids; names from ECHL_TEAM_NAMES (kept in sync with eyewall-analytics' echlConfig.js), not the body |
POST | /echl/summary/narrative?gameId=&period=&teamId=&locale= | Same as /ahl/summary/narrative (one definition in createHockeyTechLeague); KV echl:narrative:{period}:{gameId}:{teamId}[:fr] |
GET | /echl/news | ECHL news feed — only 2 sources, not AHL's 3 (echl.com has no discoverable RSS feed at all — see AHL & ECHL) |
POST | /echl/news/ingest | Ingest articles from GH Actions (nightly echl_news.py) |
POST | /echl/news/bust | Invalidate news KV cache |
GET | /echl/today?season= | Today's games with live status, period, clock and intermission. Looks ahead into the next season and the scorebar, same as /ahl/today. |
GET | /echl/live/:gameId | Live PBP + normalized events. Confirmed live against a real completed game (24296, 81 events) that ECHL's PBP has the identical event shape to AHL's, including a confirmed-real penaltyshot type (echl_penalty_shots.py) |
GET | /echl/playoff-odds?teamId=&season= | Playoff odds (contract C10) from eyewall-pipeline's hockeytech_playoff_odds.py (echl_playoff_odds, one row per team per nightly run): { latest: {season_id, team_id, run_date, make_playoffs_pct, win_division_pct, proj_points_p10/p50/p90, current_points, games_played, games_remaining, sims, format}, history: [{run_date, make_playoffs_pct, proj_points_p50}] (that season, oldest first), stale }; stale = latest run more than 48 h old. make_playoffs_pct/win_division_pct may be null (format not verified) and are passed through. Without season, the season of the team's latest run. 1 h KV (echl:playoff-odds:{teamId}:{season|latest}). Table missing or no rows: { latest: null, history: [] }; a failed read adds unavailable: true; neither is cached, never a 5xx (leagueOddsRankings.js). games_played (eyewall-pipeline#217) is null, and the answer not KV-cached, until the owner's migration adds the column: PostgREST's 400 for it is retried once with the older column list |
GET | /echl/power-rankings?teamId=&limit=&season=&locale= | Power rankings (contract C12) from hockeytech_power_rankings.py: { latest: [{team_id, rank, prior_rank, score, components}] (latest run, by rank, limitdefault and max 100), narrative: {text, run_date} | null (the team's EyeWall AI narrative from that run inlocale, echl_power_rankings_narratives), history: [{run_date, rank}] (the team's last 28 runs, oldest first) }; no teamId → narrative null, history []. 1 h KV (echl:power-rankings:{teamId|all}:{season|latest}:{limit}[:fr]). Table missing or no rows: { latest: [], narrative: null, history: [] }, uncached; a failed read adds unavailable: true |