From 24801042140fda5bf9ccf719776221ede31ef67e Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 25 May 2026 07:21:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20add=20rivalry=20platform=20integra?= =?UTF-8?q?tion=20(plan=20=C2=A713.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Top Rivalries widget to landing page and Rivals section to bot profiles, completing the platform integration for the automatic rivalry detection system. ## Changes - web/src/pages/home.ts: fetch rivalries data and render Top Rivalries card - web/src/pages/bot-profile.ts: add Rivals section filtered to this bot - web/src/styles/components.css: add rivalry list/item styles ## Plan §13.5 Platform Integration ✅ Rivalry widget on landing page with head-to-head records ✅ Bot profile pages show Rivals section with filtered rivalries ✅ Narratives already implemented via buildRivalryNarrative() Closes: bf-2quf --- web/src/pages/bot-profile.ts | 58 ++++++++++++++++-- web/src/pages/home.ts | 110 ++++++++++++++++++++++++++++++++++ web/src/styles/components.css | 42 +++++++++++++ 3 files changed, 206 insertions(+), 4 deletions(-) diff --git a/web/src/pages/bot-profile.ts b/web/src/pages/bot-profile.ts index c0df85c..73792e9 100644 --- a/web/src/pages/bot-profile.ts +++ b/web/src/pages/bot-profile.ts @@ -3,7 +3,7 @@ // below-the-fold sections, keyboard-accessible disclose toggles. // §14.10: bot profile card generation and sharing -import { fetchBotProfile, type BotProfile } from '../api-types'; +import { fetchBotProfile, fetchRivalries, type BotProfile, type RivalryEntry } from '../api-types'; import { updateOGTags, getBotProfileOGTags, resetOGTags } from '../og-tags'; import { initLazySections, lazySection } from '../lib/lazy-section'; import { downloadBotCard } from '../components/bot-card'; @@ -28,7 +28,10 @@ export async function renderBotProfilePage(params: Record): Prom if (!content) return; try { - const profile = await fetchBotProfile(botId); + const [profile, rivalriesData] = await Promise.all([ + fetchBotProfile(botId), + fetchRivalries().catch(() => ({ updated_at: '', rivalries: [] })), + ]); if (breadcrumbName) breadcrumbName.textContent = profile.name; updateOGTags(getBotProfileOGTags({ @@ -40,7 +43,7 @@ export async function renderBotProfilePage(params: Record): Prom evolved: profile.evolved, })); - renderProfile(content, profile); + renderProfile(content, profile, rivalriesData.rivalries); } catch (error) { resetOGTags(); content.innerHTML = ` @@ -53,7 +56,7 @@ export async function renderBotProfilePage(params: Record): Prom } } -function renderProfile(container: HTMLElement, profile: BotProfile): void { +function renderProfile(container: HTMLElement, profile: BotProfile, rivalries: RivalryEntry[] = []): void { const losses = profile.matches_played - profile.matches_won; container.innerHTML = ` @@ -130,6 +133,9 @@ function renderProfile(container: HTMLElement, profile: BotProfile): void { + + ${renderRivalsSection(rivalries, profile.id)} + ${lazySection( 'history', @@ -203,6 +209,50 @@ function renderMatchItem(match: BotProfile['recent_matches'][number]): string { `; } +function renderRivalsSection(rivalries: RivalryEntry[], botId: string): string { + // Filter rivalries to only those involving this bot + const botRivalries = rivalries.filter(r => r.bot_a.id === botId || r.bot_b.id === botId); + + if (botRivalries.length === 0) { + return ''; + } + + const rivalryCards = botRivalries.map(r => { + const isBotA = r.bot_a.id === botId; + const opponent = isBotA ? r.bot_b : r.bot_a; + const opponentWins = isBotA ? r.record.b_wins : r.record.a_wins; + const botWins = isBotA ? r.record.a_wins : r.record.b_wins; + const total = r.record.a_wins + r.record.b_wins + r.record.draws; + const winPct = total > 0 ? ((botWins / total) * 100).toFixed(0) : '50'; + + return ` +
+ ${escapeHtml(opponent.name)} +
+ ${botWins}-${opponentWins}${r.record.draws > 0 ? `-${r.record.draws}` : ''} + ${winPct}% win rate +
+ ${r.closest_match ? `Watch closest match` : ''} +
+ `; + }).join(''); + + return ` +
+ +
+
+ ${rivalryCards} +
+ View all rivalries +
+
+ `; +} + function initSectionToggles(container: HTMLElement): void { container.querySelectorAll('.expandable-section').forEach(section => { const toggle = section.querySelector('.section-toggle'); diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index 7acc968..a09cd40 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -9,8 +9,10 @@ import { fetchSeasonIndex, fetchMatchIndex, fetchEnrichedIndex, + fetchRivalries, type Season, type MatchSummary, + type RivalryEntry, } from '../api-types'; import { initLazySections, lazySection } from '../lib/lazy-section'; // Featured replay selection: prefer enriched/AI-commentary matches, then most recent @@ -96,6 +98,29 @@ function renderPlaylistCards(playlists: any[]): string { `).join(''); } +function renderRivalryCards(rivalries: RivalryEntry[]): string { + return rivalries.slice(0, 3).map((r: RivalryEntry) => { + const total = r.record.a_wins + r.record.b_wins + r.record.draws; + 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; + return ` + +
+ ${esc(r.bot_a.name)} + vs + ${esc(r.bot_b.name)} +
+
+
+
+
+
+
${r.record.a_wins}-${r.record.b_wins}${r.record.draws > 0 ? `-${r.record.draws}` : ''}
+
`; + }).join(''); +} + export async function renderHomePage(): Promise { const app = document.getElementById('app'); if (!app) return; @@ -108,6 +133,7 @@ export async function renderHomePage(): Promise { evolutionMeta, seasonData, matchesData, + rivalriesData, ] = await Promise.all([ fetchLeaderboard().catch(() => ({ updated_at: '', entries: [] })), fetchBlogIndex().catch(() => ({ updated_at: '', posts: [] })), @@ -131,6 +157,7 @@ export async function renderHomePage(): Promise { matches: [], pagination: { page: 1, per_page: 50, total: 0 }, })), + fetchRivalries().catch(() => ({ updated_at: '', rivalries: [] })), ]); const top5 = (leaderboardData.entries || []).slice(0, 5); @@ -243,6 +270,16 @@ export async function renderHomePage(): Promise { + + ${(rivalriesData.rivalries || []).length > 0 ? ` +
+

Top Rivalries

+
+ ${renderRivalryCards(rivalriesData.rivalries)} +
+ All rivalries → +
` : ''} + ${playlistsHtml} ${seasonHtml} ${evoHtml} @@ -504,6 +541,79 @@ export async function renderHomePage(): Promise { padding: 16px 0; } +/* Rivalries section */ +.home-rivalries { + background: var(--bg-secondary); + border-radius: 10px; + padding: 16px; + margin-bottom: 16px; +} +.home-rivalries h2 { + font-size: 1rem; + color: var(--text-primary); + margin-bottom: 12px; +} +.home-rivalry-grid { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} +.home-rivalry-card { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 12px; + text-decoration: none; + transition: transform 0.2s, box-shadow 0.2s; + border: 1px solid var(--border); +} +.home-rivalry-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + border-color: var(--accent); +} +.home-rivalry-matchup { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.home-rivalry-bot { + flex: 1; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.home-rivalry-vs { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 700; + text-transform: uppercase; +} +.home-rivalry-bar { + display: flex; + height: 6px; + border-radius: 3px; + overflow: hidden; + margin-bottom: 8px; +} +.home-rivalry-seg { + height: 100%; +} +.home-rivalry-seg.seg-a { background: #3b82f6; } +.home-rivalry-seg.seg-b { background: #ef4444; } +.home-rivalry-seg.seg-draw { background: #94a3b8; } +.home-rivalry-record { + font-size: 0.75rem; + color: var(--text-muted); + text-align: center; + font-weight: 600; +} + /* Responsive — phone (<640px) */ @media (max-width: 639px) { .home-grid { grid-template-columns: 1fr; } diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 4cb944e..00c1238 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -1350,3 +1350,45 @@ code { margin-top: var(--space-sm); width: 100%; } + +/* Rivalry section */ +.rivalry-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.rivalry-item { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm); + background-color: var(--bg-tertiary); + border-radius: var(--radius-md); +} + +.rivalry-opponent { + font-weight: 600; + color: var(--text-primary); + text-decoration: none; +} + +.rivalry-opponent:hover { + color: var(--accent); +} + +.rivalry-stats { + display: flex; + gap: var(--space-md); + font-size: 0.875rem; +} + +.rivalry-record { + font-weight: 600; + color: var(--text-secondary); +} + +.rivalry-winrate { + color: var(--text-muted); +}