♟ TNMP Architecture

"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.

Overview

Frontend (Pages)
Worker
Cloudflare Services
External
Edge / CDN
Browser APIs

👤 User's Browser

  • PWA installed or web visit
  • Service worker for offline
  • Push notification receiver
  • localStorage for cache

🔔 Push Services

  • Google FCM / Mozilla / Apple
  • Worker → push service → browser
  • RFC 8292 / 8291 encrypted

📱 Frontend Cloudflare Pages

  • Vanilla JS PWA — no framework
  • Vite build with hashed assets
  • app.js → 19 ES modules in src/
  • Chess.js + Chessground for game viewer
  • PGN editor with move validation
  • Game browser with filters + PGN import
  • Player profiles with all-time stats

🔀 Pages Function Edge

  • functions/[[path]].js
  • Intercepts crawler requests
  • Fetches /og-state from worker
  • Injects dynamic OG meta tags
  • Zero overhead for regular users
  • Adds security headers (CSP, HSTS)

⚙️ Worker Cloudflare Workers

  • HTTP API: 20 endpoints (tournament, games, push, OG)
  • Cron: 3 schedules (pairings, results, cache refresh)
  • Regex parser for MI website
  • Web Push (VAPID JWT + AES-128-GCM encryption)
  • ECO opening classification (3,641 positions)
  • OG board image generation (SVG → PNG)
  • D1 game storage + composable query API

🗄️ KV Store

  • Device registry (push subscriptions)
  • Tournament HTML cache
  • Notification state
  • OG image blobs (GAMES namespace)

🗃️ D1 Database SQLite

  • tournaments — metadata + round dates
  • games — 2,750+ games with PGN, ECO
  • players — USCF IDs, ratings, aliases
  • byes — half/full/zero-point byes
  • game_submissions — community PGN

🏛️ Mechanics' Institute

  • Tournament listing page
  • Tournament detail pages
  • Pairings, standings, PGN data
  • Polled by worker cron

Frontend

User Features

  • push.js — push notification lifecycle
  • settings.js — player name, preferences
  • style.js — piece themes, board colors, app color schemes
  • share.js — native share / clipboard
  • player-profile.js — all-time player stats

State & Data

  • config.js — constants, state getters/setters
  • games.js — /query fetch/cache, filters, explorer trie
  • eco.js — frontend ECO classification (localStorage cache)

Entry Point

  • app.js — orchestrates init, fetches state, wires events
  • Imports from nearly every module
  • Handles deep links (?game=ID)

Rendering

  • ui.js — state display, round tracker, pairing cards
  • memes.js — state-specific meme selection
  • countdown.js — auto-refresh + off-season timer

Game Panel

  • game-panel.js — modal lifecycle, viewer/editor/browser
  • Mode switching, embedded browser sidebar (desktop)
  • Receives state via onChange, routes actions to data

Data Layers

  • pgn.js — move tree, navigation, annotations, serialization
  • board.js — chess board renderer, drag-drop, legality
  • pgn-parser.js — PGN tokenizer + serializer
Infrastructure modal.js toast.js utils.js debug.js sw.js — shared by all modules

app.js

./app.js

Application entry point. Orchestrates initialization, fetches tournament state, renders UI, wires event handlers, manages deep links (?game=ID). Action dispatch table for toolbar buttons.

config.js

src/config.js

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.

WORKER_URL STATE CONFIG getTournamentMeta() setTournamentMeta() getAppState() updateAppState() SUBMISSIONS_ENABLED

ui.js

src/ui.js

All DOM rendering. Sets state-based CSS class, answer text, meme, pairing info. Renders clickable round tracker with result colors.

showState() showLoading() showError() renderRoundTracker() fitTextToContainer()

countdown.js

src/countdown.js

60-second auto-refresh timer with display. Also handles off-season countdown to next tournament start date.

startCountdown() resetCountdown() stopCountdown() startOffSeasonCountdown()

push.js

src/push.js

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.

enablePush() disablePush() checkPushStatus() syncPushSubscription() updatePushPrefs()

game-panel.js

src/game-panel.js

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.

openGamePanel() closeGamePanel() openGameWithPlayerNav() handlePanelKeydown() goToStart/Prev/Next/End() flipBoard() toggleAutoPlay() toggleNag() launchExplorer()

games.js

src/games.js

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).

onChange() getState() fetchGames() prefetchGames() getCachedGame() normalizeKey() selectPlayer() openBrowser() switchDataSource() launchExplorer() explorerPlayMove()

pgn.js

src/pgn.js

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.

initGame() destroyGame() getState() playMove() goToPrevious/goToNext() toggleAutoPlay() toggleBranchMode() toggleNag() toPgn()

board.js

src/board.js

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.

createBoard() setPosition() highlightSquares() setAutoShapes() flip() setOrientation() resize() destroy()

pgn-parser.js

src/pgn-parser.js

Parse PGN movetext into structured tokens. Handles variations, annotations (NAGs), comments. Serializes back to PGN string.

parseMoveText() extractMoveText() serializePgn() NAG_INFO

eco.js

src/eco.js

Frontend ECO classification. Loads EPD database once from /eco-data, caches in localStorage. Synchronous classifyFen() for explorer and viewer.

loadEcoData() classifyFen() findOpeningByName()

modal.js

src/modal.js

Generic modal open/close/focus management with keyboard trap and backdrop click-to-close.

settings.js

src/settings.js

Settings modal — player name, push enable/disable, notification prefs.

style.js

src/style.js

Style modal — piece themes, board colors, and app color schemes. Persists to localStorage, applies via dynamic <style> element.

player-profile.js

src/player-profile.js

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.

openPlayerProfile()

memes.js

src/memes.js

Random meme selection per state. 36 memes (6 states × 6 each).

share.js

src/share.js

Native Share API with clipboard fallback for sharing pairings/games.

utils.js

src/utils.js

Helpers: name formatting, result symbols, PGN header extraction.

toast.js

src/toast.js

Temporary toast messages ("Saved!", "Copied!").

debug.js

src/debug.js

Debug panel. previewState() renders debug state overlays for testing.

sw.js

public/sw.js

Service worker. Network-first for HTML shell, cache-first for assets/memes/pieces. Push notification display with delivery ack, click tracking via worker API.

install activate fetch push notificationclick

Worker

Parsing

  • parser.js — regex single-pass extraction
  • Pairings, standings, PGN, round dates
  • No imports (standalone)

ECO & Board

  • eco.js — position-based ECO classification
  • og-board.js — SVG board for OG images
  • 3,641 opening positions via chess.js

Router

  • index.js — HTTP router (20 routes) + cron dispatcher
  • Routes HTTP to Tournament, Push, Game Data
  • fetch() and scheduled() entry points

Cron Pipeline

  • cron.js — fetch MI page, parse, cache, persist
  • Upsert games to D1, classify ECO openings
  • Dispatch push notifications on state change

Tournament

  • tournament.js — resolve from MI listing
  • Lifecycle: active → grace period → next
  • Serves cached HTML + metadata to frontend

Push

  • push.js — device registry, dispatch, retry
  • webpush.js — VAPID JWT + AES-128-GCM
  • Delivery tracking (ack/click), 90-day KV TTL

Game Data

  • games.js — D1 queries, composable filters
  • Single game, list, player history, OG images
  • Game submission + ECO backfill endpoints
Infrastructure helpers.js — CORS, name normalization, slug generation (shared by all modules)

index.js

worker/src/index.js

HTTP router (20 routes) and cron dispatcher. CORS handling. Entry point for both fetch and scheduled events.

fetch() scheduled()

tournament.js

worker/src/tournament.js

Tournament resolution from D1. Queries current + next tournament by round dates. Falls back to MI listing only during transitions (~7×/year).

resolveTournament() handleTournamentHtml() handleTournamentState() handleOgState()

cron.js

worker/src/cron.js

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.

handleScheduled()

parser.js

worker/src/parser.js

Regex-based HTML parser. Single-pass parseTournamentPage() extracts pairings, standings, PGN, round dates, tournament name from MI HTML.

parseTournamentPage() hasPairings() hasResults() findPlayerPairing() composeMessage() composeResultsMessage()

push.js

worker/src/push.js

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.

handlePushSubscribe() handlePushUnsubscribe() handlePushStatus() handlePushPreferences() handlePushAck() handlePushClick() dispatchPushNotifications() retryPendingNotifications() listPushSubscriptions()

webpush.js

worker/src/webpush.js

RFC 8292 (VAPID) + RFC 8291 (aes128gcm) implementation. Builds JWT, encrypts payload with ECDH + AES-128-GCM, POSTs to push endpoint.

sendPushNotification()

games.js

worker/src/games.js

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.

handleQuery() handlePlayers() handleTournaments() handleSubmitGame() handleOgGame() handleOgGameImage()

eco.js

worker/src/eco.js

Position-based ECO classification. Replays moves to FEN, matches against 3,641 lichess opening positions.

classifyOpening() classifyFen()

og-board.js

worker/src/og-board.js

Chess board SVG generation for social media preview images. Renders position with player names, ratings, result, ECO.

generateBoardSvg()

helpers.js

worker/src/helpers.js

CORS headers, player name normalization, tournament slug generation, player name pattern matching.

corsHeaders() normalizePlayerName() slugifyTournament() formatPlayerName()

Data Flow

🏛️ MI Website

Tournament HTML with pairings, standings, PGN textareas

cron fetch (1–5 min)

⚙️ Worker Cron

handleScheduled()parseTournamentPage()

Single-pass extract: PGN colors, pairings, standings, round dates

persist

🗄️ KV + D1

HTML cache → KV

Games, byes, players, tournaments → D1

if new pairings/results

🔔 Push Dispatch

Filter by prefs → compose message → encrypt + send

👤 User Enables Push

Settings → toggle on

Browser prompts for Notification permission

permission granted

📱 PushManager.subscribe()

Generate stable UUID (pushDeviceId in localStorage)

Derive device label from UA ("Safari on iPhone")

Returns { endpoint, keys: { p256dh, auth } }

POST /push-subscribe + deviceId

⚙️ Worker Creates Device Record

KV key = device:{uuid} (90-day TTL)

Stores: endpoint, keys, playerName, deviceLabel, prefs, tracking fields

Migrates any legacy push:{hash} record

👤 User Taps "Check Again"

Or: auto-refresh every 60 seconds

📱 Instant Render

Load cached state from localStorage

Show immediately (no spinner)

concurrent network fetch

⚙️ Worker API

/tournament-state + /query

merge & re-render

📱 Update UI

Answer + meme + pairing card + round tracker

Save to localStorage for next visit

🤖 Crawler Request

Facebook, Twitter, Discord, Slack, etc.

detected by User-Agent

🔀 Pages Function

Fetch /og-state or /og-game from worker

inject into HTML

📋 Dynamic OG Tags

State-specific title, description, image, color

Game links get board preview image

🔄 Endpoint Rotation

Browser may change endpoint at any time

syncPushSubscription() sends deviceId on page load

Same device:{uuid} key — updated in place, no orphan

|

🗑️ Gone & Dormant

410/404 → delete device record immediately

5 consecutive failures → mark dormant, stop retrying

90-day TTL auto-expires inactive records

⏰ Cron Detects Pairings/Results

hasPairings() or hasResults() returns true

Check state:pairingsUp — not yet notified for this round

📋 List Devices

KV list prefixes device: + push: (paginated)

For each: check notifyPairings/notifyResults pref

for each device

🔍 Find Player Pairing

findPlayerPairingFromSections()

Match by player name patterns (First Last / Last, First)

✉️ Compose + Send

Payload includes deviceId + expiresAt TTL

VAPID JWT → ECDH → AES-128-GCM → push service

📊 Track Result

✅ Success: update lastDeliveredAt, reset failCount

❌ 410/404: delete device record (gone)

⚠️ 429/5xx: set retryAfter, increment failCount

🔔 Push Service Delivers

Google FCM / Mozilla / Apple push service

Wakes service worker on user's device

📳 Service Worker: push event

Parse payload → showNotification()

Fire /push-ack?deviceId=... → updates lastDisplayedAt

user taps notification

👤 notificationclick

Fire /push-click?deviceId=... → updates lastClickedAt

Focus existing TNMP tab, or open new window

next cron cycle

🔄 Retry Pending

Scan for devices with retryAfter in the past

Skip if expiresAt passed or failCount ≥ 5 (dormant)

Storage

SUBSCRIBERS

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.

GAMES

Cached OG board images for social media previews (SVG-rendered chess positions, stored as PNG blobs with 2-day TTL).

tournaments

ColumnTypeNotes
slugTEXT PKURL-safe identifier
nameTEXTFull tournament name
short_codeTEXTCompact code for filenames
round_datesTEXTJSON array of ISO date strings
urlTEXTTournament page URL

games

ColumnTypeNotes
idINTEGER PKAuto-increment
tournament_slugTEXT FK→ tournaments.slug
roundINTEGERRound number
boardINTEGERBoard number
white / blackTEXTOriginal names
white_norm / black_normTEXTLowercase normalized
white_elo / black_eloINTEGERRatings
resultTEXT1-0, 0-1, 1/2-1/2
ecoTEXTECO code (B30, etc.)
opening_nameTEXTFull opening name
sectionTEXTRating section
dateTEXTGame date
game_idTEXT UNIQUESHA hash for deep links
pgnTEXTFull PGN movetext

Indexes: (tournament_slug, round, board), (game_id), (white_norm), (black_norm)

byes

ColumnTypeNotes
tournament_slugTEXTComposite PK
roundINTEGER
player_normTEXT
bye_typeTEXThalf, full, zero

players

ColumnTypeNotes
idINTEGER PKAuto-increment
nameTEXTDisplay name (Last, First)
name_normTEXT UNIQUECanonical key
uscf_idTEXTUS Chess member ID
aliasesTEXTJSON array of name variants
ratingINTEGERCurrent USCF regular rating
rating_updated_atTEXTLast rating refresh
rating_historyTEXTJSON supplement history
KeyPurposeUpdated 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

Endpoints

RouteMethodPurpose
/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 ExpressionFrequencyPacific TimePurpose
* 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

API

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.

GET /tournaments

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.

Response Shape

tournaments array of:

FieldTypeDescription
slugstringURL-safe identifier (use in /query?tournament=)
namestringFull display name
roundDatesarrayISO date strings, one per round
urlstring|nullMI tournament page
uscfEventIdstring|nullUS 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
  ]
}

GET /players

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.

Response Shape

players array of:

FieldTypeDescription
namestring"First Last" display format
dbNamestring"Last, First" database format
uscfIdstring|nullUS Chess member ID
ratinginteger|nullCurrent USCF regular rating
Name resolution: Either 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
  ]
}

GET /query

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.

Name resolution: Player names are resolved through the alias system automatically. Old name variants, different capitalizations, and "First Last" or "Last, First" formats all resolve to the same canonical player.
Basic requestGET /query

// Returns up to 100 games from the current tournament
With filtersGET /query?player=Boyer, John&tournament=all&include=pgn

Tournament Scope

UsageBehavior
tournament omitted Current tournament only (resolved from D1)
tournament=all Search across all tournaments
tournament={slug} Specific tournament by slug
Finding slugs: Slugs follow the pattern 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

Filters

ParameterDescription
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

Pagination & Output

ParameterDescription
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 text
submissions — include pending community PGNs
PGN is excluded by default to keep responses small. Add 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

Response Shape

games array of:

FieldTypeDescription
tournamentstringDisplay name
tournamentSlugstringURL-safe slug
roundintegerRound number
boardintegerBoard number
white / blackstring"First Last" display format
whiteNorm / blackNormstring"last,first" canonical key
whiteElo / blackElointeger|nullRating at time of game
resultstring1-0, 0-1, 1/2-1/2, or *
ecostring|nullECO classification code
openingNamestring|nullFull opening name
sectionstringe.g. "2000+", "1600-1999"
datestring"YYYY.MM.DD"
gameIdstring|nullGlobally unique game ID
hasPgnbooleanWhether PGN moves exist
pgnstringOnly with include=pgn

Top-level fields:

FieldTypeDescription
totalintegerTotal matching games (for pagination)
limit / offsetintegerEcho of pagination params
byesarrayOnly with player + specific tournament. Each: { round, type }
uscfIdstringPlayer'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"
}

Recipe Book

# 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

Try It

Run live queries against the API. Responses come directly from api.tnmpairings.com.

GET /query?tournament=all
Click "Send" to run a query...