From 736b0f1bd1e99797be978d77eeef7323606c3d8f Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 25 May 2026 08:24:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20add=20individual=20rivalry=20page?= =?UTF-8?q?=20route=20(plan=20=C2=A713.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /rivalry/:bot_a/:bot_b route showing detailed head-to-head history: - Win rates, draws, match count - Recent matches list - Longest streak highlight - Narrative description - Links to bot profiles and replays Co-Authored-By: Claude Opus 4.7 --- web/src/app.ts | 5 + web/src/pages/rivalry.ts | 318 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 web/src/pages/rivalry.ts diff --git a/web/src/app.ts b/web/src/app.ts index 22742eb..8492667 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -42,6 +42,7 @@ function getSkeletonHtml(path: string): string { if (path === '/evolution') return skeletonEvolution(); if (path.startsWith('/blog')) return skeletonBlog(); if (path === '/seasons' || path.startsWith('/season/')) return skeletonSeasons(); + if (path === '/rivalries' || path.startsWith('/rivalry/')) return skeletonGeneric('Rivalries'); if (path === '/watch/predictions' || path === '/predictions') return skeletonGeneric('Predictions'); if (path === '/watch') return skeletonGeneric('Watch'); if (path === '/') return ''; // Home page has its own rich skeleton built in @@ -90,6 +91,8 @@ const loadFeedbackPage = () => import('./pages/feedback').then(async m => { const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsApiPage); // Rivalries page (pre-computed from index builder §13.5) const loadRivalriesPage = () => import('./pages/rivalries').then(m => m.renderRivalriesPage); +// Individual rivalry page (§13.5) +const loadRivalryPage = () => import('./pages/rivalry').then(m => m.renderRivalryPage); // Embed page (minimal replay viewer for iframe embedding §13.4) const loadEmbedPage = () => import('./pages/embed').then(m => m.renderEmbedPage); @@ -281,6 +284,8 @@ router .on('/feedback', lazyRoute(loadFeedbackPage)) .on('/compete/feedback', lazyRoute(loadFeedbackPage)) .on('/compete/docs/api', lazyRoute(loadDocsApiPage)) + .on('/rivalries', lazyRoute(loadRivalriesPage)) + .on('/rivalry/:bot_a/:bot_b', lazyRoute(loadRivalryPage)) .on('/embed/:id', lazyRoute(loadEmbedPage)) .notFound(lazyRoute(loadNotFoundPage)); diff --git a/web/src/pages/rivalry.ts b/web/src/pages/rivalry.ts new file mode 100644 index 0000000..c6340a6 --- /dev/null +++ b/web/src/pages/rivalry.ts @@ -0,0 +1,318 @@ +// Individual rivalry page: /rivalry/{bot_a}/{bot_b} +// Shows detailed head-to-head history between two bots (§13.5) + +import { fetchRivalries, type RivalryEntry } from '../api-types'; + +// ─── Page render ───────────────────────────────────────────────────────────── + +export async function renderRivalryPage(params: Record): Promise { + const app = document.getElementById('app'); + if (!app) return; + + const botA = params.bot_a; + const botB = params.bot_b; + + app.innerHTML = ` +
+

Rivalry

+
Loading rivalry…
+
+ ${RIVALRY_STYLES} + `; + + const content = document.getElementById('rivalry-content')!; + + try { + const { rivalries } = await fetchRivalries(); + + // Find the rivalry entry for this pair (order-agnostic) + const rivalry = rivalries.find(r => + (r.bot_a.id === botA && r.bot_b.id === botB) || + (r.bot_a.id === botB && r.bot_b.id === botA) + ); + + if (!rivalry) { + content.innerHTML = ` +
+

No rivalry found between these bots.

+

Rivalries appear when two bots have played at least 10 head-to-head matches. + View all rivalries

+
+ `; + return; + } + + // Normalize so botA is always first + const isReversed = rivalry.bot_a.id !== botA; + const entry = isReversed ? swapRivalry(rivalry) : rivalry; + + renderRivalryDetail(content, entry); + } catch (err) { + content.innerHTML = `
Failed to load rivalry: ${err}
`; + } +} + +// ─── Detail renderer ────────────────────────────────────────────────────────── + +function renderRivalryDetail(container: HTMLElement, r: RivalryEntry): void { + const total = r.matches; + const pctA = total > 0 ? (r.record.a_wins / total) * 100 : 50; + const pctD = total > 0 ? (r.record.draws / total) * 100 : 0; + const pctB = 100 - pctA - pctD; + + container.innerHTML = ` +
+
+
+ ${escapeHtml(r.bot_a.name)} + ${r.record.a_wins}W +
+
VS
+
+ ${escapeHtml(r.bot_b.name)} + ${r.record.b_wins}W +
+
+ +
+
+
${total}
+
Matches
+
+
+
${pctA.toFixed(0)}%
+
${r.bot_a.name} Win Rate
+
+ ${r.record.draws > 0 ? ` +
+
${r.record.draws}
+
Draws
+
` : ''} +
+ +
+
+
+
+
+ +

${escapeHtml(r.narrative)}

+ + ${r.longest_streak ? ` +
+ 🔥 + ${escapeHtml(r.longest_streak.holder)}'s ${r.longest_streak.length}-win streak +
` : ''} + +
+

Recent Matches

+ ${r.recent_matches.length > 0 ? ` +
+ ${r.recent_matches.map(matchId => ` + Match ${matchId} + `).join('')} +
` : '

No recent matches available.

'} +
+ + ${r.closest_match ? ` + ` : ''} + + +
+ `; +} + +// Swap bot_a/b and their stats when rivalry is queried in reverse order +function swapRivalry(r: RivalryEntry): RivalryEntry { + return { + ...r, + bot_a: r.bot_b, + bot_b: r.bot_a, + record: { + a_wins: r.record.b_wins, + b_wins: r.record.a_wins, + draws: r.record.draws, + }, + }; +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const RIVALRY_STYLES = ` + +`;