"Are the Pairings Up?" — Chess tournament pairings checker PWA
A real-time chess tournament companion for the Tuesday Night Marathon at San Francisco's Mechanics' Institute. Vanilla JS progressive web app on Cloudflare's edge stack — Pages, Workers, D1, KV. No framework, ~215KB bundled.
app.js → 19 ES modules in src/functions/[[path]].js/og-state from workertournaments — metadata + round datesgames — 2,750+ games with PGN, ECOplayers — USCF IDs, ratings, aliasesbyes — half/full/zero-point byesgame_submissions — community PGNpush.js — push notification lifecyclesettings.js — player name, preferencesstyle.js — piece themes, board colors, app color schemesshare.js — native share / clipboardplayer-profile.js — all-time player statsconfig.js — constants, state getters/settersgames.js — /query fetch/cache, filters, explorer trieeco.js — frontend ECO classification (localStorage cache)app.js — orchestrates init, fetches state, wires events?game=ID)ui.js — state display, round tracker, pairing cardsmemes.js — state-specific meme selectioncountdown.js — auto-refresh + off-season timergame-panel.js — modal lifecycle, viewer/editor/browseronChange, routes actions to datapgn.js — move tree, navigation, annotations, serializationboard.js — chess board renderer, drag-drop, legalitypgn-parser.js — PGN tokenizer + serializerApplication entry point. Orchestrates initialization, fetches tournament state, renders UI, wires event handlers, manages deep links (?game=ID). Action dispatch table for toolbar buttons.
Constants, persistent settings, and centralized app state. Worker URL, VAPID key, STATE enum, player name (localStorage), tournament metadata. Getter/setter pairs for all mutable state.
All DOM rendering. Sets state-based CSS class, answer text, meme, pairing info. Renders clickable round tracker with result colors.
60-second auto-refresh timer with display. Also handles off-season countdown to next tournament start date.
Push notification lifecycle. Generates stable device UUID on first enable, derives device label from UA. Subscribe via PushManager, POST to worker with deviceId, sync on endpoint rotation.
View/controller for the unified game panel. Orchestrates modal lifecycle, viewer↔editor mode switching, embedded browser sidebar, keyboard dispatch. Receives state from games.js via onChange — never calls getters.
Pure data layer. Fetches /query, caches games, builds indexes by round/player/section, opening explorer trie, player search, tournament switching. Zero DOM. Pushes complete state via onChange(callback).
Pure data layer for the move tree. Manages navigation, variations, annotations, comments, auto-play, branch mode, PGN serialization. Receives user moves from board.js via playMove(san). Zero DOM.
Chess board renderer. Accepts positions, renders via Chessground, handles drag-drop/click-to-move, validates legality via chess.js. Reports user moves via onMove callback. Zero knowledge of PGN tree.
Parse PGN movetext into structured tokens. Handles variations, annotations (NAGs), comments. Serializes back to PGN string.
Frontend ECO classification. Loads EPD database once from /eco-data, caches in localStorage. Synchronous classifyFen() for explorer and viewer.
Generic modal open/close/focus management with keyboard trap and backdrop click-to-close.
Settings modal — player name, push enable/disable, notification prefs.
Style modal — piece themes, board colors, and app color schemes. Persists to localStorage, applies via dynamic <style> element.
Player profile modal. Fetches all-time stats from /query?player=NAME&tournament=all. Win/loss/draw by tournament, opening repertoire by color, opponent history. Navigates to explorer/browser on row click.
Random meme selection per state. 36 memes (6 states × 6 each).
Native Share API with clipboard fallback for sharing pairings/games.
Helpers: name formatting, result symbols, PGN header extraction.
Temporary toast messages ("Saved!", "Copied!").
Debug panel. previewState() renders debug state overlays for testing.
Service worker. Network-first for HTML shell, cache-first for assets/memes/pieces. Push notification display with delivery ack, click tracking via worker API.
parser.js — regex single-pass extractioneco.js — position-based ECO classificationog-board.js — SVG board for OG imagesindex.js — HTTP router (20 routes) + cron dispatcherfetch() and scheduled() entry pointscron.js — fetch MI page, parse, cache, persisttournament.js — resolve from MI listingpush.js — device registry, dispatch, retrywebpush.js — VAPID JWT + AES-128-GCMgames.js — D1 queries, composable filtersHTTP router (20 routes) and cron dispatcher. CORS handling. Entry point for both fetch and scheduled events.
Tournament resolution from D1. Queries current + next tournament by round dates. Falls back to MI listing only during transitions (~7×/year).
Cron handler. Fetches MI page, parses in single pass, caches HTML in KV, ingests games + byes to D1, auto-creates new player records from US Chess API, dispatches push notifications.
Regex-based HTML parser. Single-pass parseTournamentPage() extracts pairings, standings, PGN, round dates, tournament name from MI HTML.
Device registry with stable UUIDs, subscription CRUD, delivery tracking (ack/click), notification dispatch with retry. 90-day KV TTL on all records, reset on any proof-of-life.
RFC 8292 (VAPID) + RFC 8291 (aes128gcm) implementation. Builds JWT, encrypts payload with ECDH + AES-128-GCM, POSTs to push endpoint.
D1-based game endpoints. Composable /query API with filters (player, tournament, round, section, eco, result, rating range). Returns games + byes. Player list, OG images, ECO classification, game submissions.
Position-based ECO classification. Replays moves to FEN, matches against 3,641 lichess opening positions.
Chess board SVG generation for social media preview images. Renders position with player names, ratings, result, ECO.
CORS headers, player name normalization, tournament slug generation, player name pattern matching.
Tournament HTML with pairings, standings, PGN textareas
handleScheduled() → parseTournamentPage()
Single-pass extract: PGN colors, pairings, standings, round dates
HTML cache → KV
Games, byes, players, tournaments → D1
Filter by prefs → compose message → encrypt + send
Settings → toggle on
Browser prompts for Notification permission
Generate stable UUID (pushDeviceId in localStorage)
Derive device label from UA ("Safari on iPhone")
Returns { endpoint, keys: { p256dh, auth } }
KV key = device:{uuid} (90-day TTL)
Stores: endpoint, keys, playerName, deviceLabel, prefs, tracking fields
Migrates any legacy push:{hash} record
Or: auto-refresh every 60 seconds
Load cached state from localStorage
Show immediately (no spinner)
/tournament-state + /query
Answer + meme + pairing card + round tracker
Save to localStorage for next visit
Facebook, Twitter, Discord, Slack, etc.
Fetch /og-state or /og-game from worker
State-specific title, description, image, color
Game links get board preview image
Browser may change endpoint at any time
syncPushSubscription() sends deviceId on page load
Same device:{uuid} key — updated in place, no orphan
410/404 → delete device record immediately
5 consecutive failures → mark dormant, stop retrying
90-day TTL auto-expires inactive records
hasPairings() or hasResults() returns true
Check state:pairingsUp — not yet notified for this round
KV list prefixes device: + push: (paginated)
For each: check notifyPairings/notifyResults pref
findPlayerPairingFromSections()
Match by player name patterns (First Last / Last, First)
Payload includes deviceId + expiresAt TTL
VAPID JWT → ECDH → AES-128-GCM → push service
✅ Success: update lastDeliveredAt, reset failCount
❌ 410/404: delete device record (gone)
⚠️ 429/5xx: set retryAfter, increment failCount
Google FCM / Mozilla / Apple push service
Wakes service worker on user's device
Parse payload → showNotification()
Fire /push-ack?deviceId=... → updates lastDisplayedAt
Fire /push-click?deviceId=... → updates lastClickedAt
Focus existing TNMP tab, or open new window
Scan for devices with retryAfter in the past
Skip if expiresAt passed or failCount ≥ 5 (dormant)
Device registry for push notifications (stable UUIDs, delivery tracking, retry state). Tournament HTML cache, pre-computed app state, notification deduplication state. All records use TTL-based expiration.
Cached OG board images for social media previews (SVG-rendered chess positions, stored as PNG blobs with 2-day TTL).
tnmp-games database| Column | Type | Notes |
|---|---|---|
slug | TEXT PK | URL-safe identifier |
name | TEXT | Full tournament name |
short_code | TEXT | Compact code for filenames |
round_dates | TEXT | JSON array of ISO date strings |
url | TEXT | Tournament page URL |
| Column | Type | Notes |
|---|---|---|
id | INTEGER PK | Auto-increment |
tournament_slug | TEXT FK | → tournaments.slug |
round | INTEGER | Round number |
board | INTEGER | Board number |
white / black | TEXT | Original names |
white_norm / black_norm | TEXT | Lowercase normalized |
white_elo / black_elo | INTEGER | Ratings |
result | TEXT | 1-0, 0-1, 1/2-1/2 |
eco | TEXT | ECO code (B30, etc.) |
opening_name | TEXT | Full opening name |
section | TEXT | Rating section |
date | TEXT | Game date |
game_id | TEXT UNIQUE | SHA hash for deep links |
pgn | TEXT | Full PGN movetext |
Indexes: (tournament_slug, round, board), (game_id), (white_norm), (black_norm)
| Column | Type | Notes |
|---|---|---|
tournament_slug | TEXT | Composite PK |
round | INTEGER | |
player_norm | TEXT | |
bye_type | TEXT | half, full, zero |
| Column | Type | Notes |
|---|---|---|
id | INTEGER PK | Auto-increment |
name | TEXT | Display name (Last, First) |
name_norm | TEXT UNIQUE | Canonical key |
uscf_id | TEXT | US Chess member ID |
aliases | TEXT | JSON array of name variants |
rating | INTEGER | Current USCF regular rating |
rating_updated_at | TEXT | Last rating refresh |
rating_history | TEXT | JSON supplement history |
| Key | Purpose | Updated By |
|---|---|---|
playerName |
Selected player name for personalized pairings | Settings modal |
lastTournamentState |
Last fetched tournament state (instant render on revisit) | app.js on each check |
gamesData |
Cached games from /query endpoint (flat array + query params) | games.js |
eco-epd-data-v2 |
Full EPD→ECO mapping (3,641 positions with pgn/uci) for frontend classification | eco.js |
pushDeviceId |
Stable device UUID (generated on first push enable, survives endpoint rotation) | push.js |
pushEndpoint |
Current push subscription endpoint (for rotation detection) | push.js |
aboutSeen |
First-visit About modal shown flag | app.js |
| Route | Method | Purpose |
|---|---|---|
/tournaments |
GET | List all tournaments (slugs, names, round dates) |
/players |
GET | List all players (names, USCF IDs, ratings) |
/query |
GET | Composable game queries with filters (player, tournament, round, ECO, rating range, date). See API documentation below. |
17 additional internal endpoints for tournament state, push notifications (device registry, delivery tracking, retry), OG image generation, ECO classification, cron triggers, and admin operations. All internal endpoints require either frontend origin (CORS) or VAPID key authentication.
| Cron Expression | Frequency | Pacific Time | Purpose |
|---|---|---|---|
* 2-8 * * TUE |
Every 1 min | Mon 7PM – midnight (PDT) / 6PM (PST) | Check pairings (TNM Monday night) |
*/5 2-8 * * WED |
Every 5 min | Tue 6PM – midnight (PDT) / 7PM (PST) | Check results (TNM Tuesday night) |
*/20 * * * * |
Every 20 min | 24/7 | Cache refresh + background notification check |
The TNMP API provides open access to game data from the Tuesday Night Marathon at San Francisco's Mechanics' Institute. Query historical games, player records, and tournament metadata. Built for fellow chess players, club organizers, and anyone curious about the data.
Three endpoints, all GET, no authentication required. Discover tournaments and players first, then query games with composable filters.
List all tournaments in the database. Returns slugs (used as filter values in /query), display names, round dates, and MI page URLs.
Sorted by start date, most recent first. No parameters.
tournaments array of:
| Field | Type | Description |
|---|---|---|
| slug | string | URL-safe identifier (use in /query?tournament=) |
| name | string | Full display name |
| roundDates | array | ISO date strings, one per round |
| url | string|null | MI tournament page |
| uscfEventId | string|null | US Chess rated event ID |
RequestGET /tournaments
Response{ "tournaments": [ { "slug": "2026-spring-tuesday-night-marathon", "name": "2026 Spring Tuesday Night Marathon", "roundDates": ["2026-03-03", "2026-03-11", ...], "url": "https://www.milibrary.org/chess/...", "uscfEventId": null }, // ... more tournaments ] }
List all players who have appeared in any tournament. Returns display names, database names (for use in /query), USCF IDs, and current ratings.
Sorted alphabetically by display name. No parameters.
players array of:
| Field | Type | Description |
|---|---|---|
| name | string | "First Last" display format |
| dbName | string | "Last, First" database format |
| uscfId | string|null | US Chess member ID |
| rating | integer|null | Current USCF regular rating |
name ("First Last") or dbName ("Last, First") can be used as the player parameter in /query. The alias system resolves both.
RequestGET /players
Response{ "players": [ { "name": "John Boyer", "dbName": "Boyer, John", "uscfId": "16865157", "rating": 1761 }, // ... 300+ players ] }
Composable game query endpoint backed by D1. Returns games with optional PGN text, byes, and USCF IDs. All filters are optional and combine with AND logic.
By default, queries are scoped to the current tournament. Use tournament=all to search across all tournaments, or pass a specific slug from /tournaments.
Basic requestGET /query // Returns up to 100 games from the current tournament
With filtersGET /query?player=Boyer, John&tournament=all&include=pgn
| Usage | Behavior |
|---|---|
| tournament omitted | Current tournament only (resolved from D1) |
tournament=all |
Search across all tournaments |
tournament={slug} |
Specific tournament by slug |
YYYY-season-tuesday-night-marathon. Hit /tournaments to list all available slugs.
Current tournamentGET /query All tournamentsGET /query?tournament=all Specific tournamentGET /query?tournament=2026-spring-tuesday-night-marathon
| Parameter | Description |
|---|---|
| player string | Player name. Matches white or black. Alias-resolved. e.g. Boyer, John or John Boyer |
| color string | white or black. Requires player |
| opponent string | Filter to games between player and this opponent |
| result string | win, loss, or draw. Relative to player |
| round integer | Round number |
| board integer | Board number |
| gameId string | Globally unique game ID. Ignores tournament scope |
| eco string | ECO code or range. B23 or B00-B99 |
| section string | Section name, e.g. 2000+, 1600-1999 |
| minRating integer | Opponent's minimum rating. Requires player |
| maxRating integer | Opponent's maximum rating. Requires player |
| after date | Games on or after this date. YYYY-MM-DD |
| before date | Games on or before this date. YYYY-MM-DD |
Player + color + resultGET /query?player=Boyer, John &tournament=all &color=white &result=win ECO rangeGET /query?tournament=all&eco=B00-B99 Head to headGET /query?player=Boyer, John &opponent=Smith, Alice &tournament=all Rating filterGET /query?player=Boyer, John &tournament=all &minRating=1800 &maxRating=2200 Date rangeGET /query?tournament=all &after=2025-01-01 &before=2025-12-31
| Parameter | Description |
|---|---|
| limit integer | Results per page. Default 100, max 500 |
| offset integer | Skip this many results. Default 0 |
| include string |
Comma-separated extras:pgn — include full PGN textsubmissions — include pending community PGNs
|
include=pgn when you need move data.
Paginated with PGNGET /query?tournament=all &limit=50 &offset=100 &include=pgn Multiple includesGET /query?include=pgn,submissions
games array of:
| Field | Type | Description |
|---|---|---|
| tournament | string | Display name |
| tournamentSlug | string | URL-safe slug |
| round | integer | Round number |
| board | integer | Board number |
| white / black | string | "First Last" display format |
| whiteNorm / blackNorm | string | "last,first" canonical key |
| whiteElo / blackElo | integer|null | Rating at time of game |
| result | string | 1-0, 0-1, 1/2-1/2, or * |
| eco | string|null | ECO classification code |
| openingName | string|null | Full opening name |
| section | string | e.g. "2000+", "1600-1999" |
| date | string | "YYYY.MM.DD" |
| gameId | string|null | Globally unique game ID |
| hasPgn | boolean | Whether PGN moves exist |
| pgn | string | Only with include=pgn |
Top-level fields:
| Field | Type | Description |
|---|---|---|
| total | integer | Total matching games (for pagination) |
| limit / offset | integer | Echo of pagination params |
| byes | array | Only with player + specific tournament. Each: { round, type } |
| uscfId | string | Player's USCF ID. Only with player filter |
Example response{ "games": [ { "tournament": "2026 Spring Tuesday Night Marathon", "tournamentSlug": "2026-spring-tuesday-night-marathon", "round": 2, "board": 27, "white": "John Boyer", "black": "Bennett Mccutcheon", "whiteNorm": "boyer,john", "blackNorm": "mccutcheon,bennett", "whiteElo": 1761, "blackElo": 1565, "result": "1-0", "eco": "C47", "openingName": "Four Knights: Naroditsky", "section": "1600-1999", "date": "2026.03.10", "gameId": "2286569907722448", "hasPgn": true, "pgn": "[Event \"2026 Spring TNM\"] ..." } ], "total": 2, "limit": 100, "offset": 0, "byes": [ { "round": 3, "type": "half" } ], "uscfId": "16865157" }
# My games this tournament GET /query?player=Boyer, John # All my games ever, with PGNs GET /query?player=Boyer, John&tournament=all&include=pgn # Round 2 of current tournament GET /query?round=2 # Specific game by ID GET /query?gameId=2286569907722448&include=pgn # All Sicilian games across all tournaments GET /query?tournament=all&eco=B20-B99 # My wins as white against 1800+ opponents GET /query?player=Boyer, John&tournament=all&color=white&result=win&minRating=1800 # All 2025 games GET /query?tournament=all&after=2025-01-01&before=2025-12-31&limit=500 # Page 2 of results (games 101-200) GET /query?tournament=all&limit=100&offset=100
Run live queries against the API. Responses come directly from api.tnmpairings.com.