From 0ea9da886e554b3e59ce9492133c088d14c4d031 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:55:18 +0200 Subject: [PATCH 01/24] feat: add Arena leaderboard page for AI coding agents and LLMs Introduces the Arena page with D-Index rankings, crown cards, sentiment bars, sparkline charts, highlights feed, and live ticker. Uses SSR with Cache-Control headers (s-maxage=60) to avoid rate-limiting the endpoint. --- docs/spec-agents-arena.md | 279 +++++++++++++++ .../agents/arena/ArenaAnimatedCounter.tsx | 54 +++ .../features/agents/arena/ArenaCrownCards.tsx | 124 +++++++ .../agents/arena/ArenaHighlightsFeed.tsx | 233 ++++++++++++ .../features/agents/arena/ArenaLiveTicker.tsx | 145 ++++++++ .../src/features/agents/arena/ArenaPage.tsx | 183 ++++++++++ .../features/agents/arena/ArenaRankings.tsx | 308 ++++++++++++++++ .../agents/arena/ArenaSentimentBar.tsx | 51 +++ .../features/agents/arena/ArenaSparkline.tsx | 81 +++++ .../src/features/agents/arena/config.ts | 166 +++++++++ .../src/features/agents/arena/dindex.ts | 332 ++++++++++++++++++ .../src/features/agents/arena/graphql.ts | 62 ++++ .../src/features/agents/arena/mockData.ts | 222 ++++++++++++ .../src/features/agents/arena/queries.ts | 23 ++ .../shared/src/features/agents/arena/types.ts | 96 +++++ packages/shared/src/lib/query.ts | 1 + packages/shared/src/styles/utilities.css | 18 + packages/webapp/pages/agents/arena.tsx | 69 ++++ 18 files changed, 2447 insertions(+) create mode 100644 docs/spec-agents-arena.md create mode 100644 packages/shared/src/features/agents/arena/ArenaAnimatedCounter.tsx create mode 100644 packages/shared/src/features/agents/arena/ArenaCrownCards.tsx create mode 100644 packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx create mode 100644 packages/shared/src/features/agents/arena/ArenaLiveTicker.tsx create mode 100644 packages/shared/src/features/agents/arena/ArenaPage.tsx create mode 100644 packages/shared/src/features/agents/arena/ArenaRankings.tsx create mode 100644 packages/shared/src/features/agents/arena/ArenaSentimentBar.tsx create mode 100644 packages/shared/src/features/agents/arena/ArenaSparkline.tsx create mode 100644 packages/shared/src/features/agents/arena/config.ts create mode 100644 packages/shared/src/features/agents/arena/dindex.ts create mode 100644 packages/shared/src/features/agents/arena/graphql.ts create mode 100644 packages/shared/src/features/agents/arena/mockData.ts create mode 100644 packages/shared/src/features/agents/arena/queries.ts create mode 100644 packages/shared/src/features/agents/arena/types.ts create mode 100644 packages/webapp/pages/agents/arena.tsx diff --git a/docs/spec-agents-arena.md b/docs/spec-agents-arena.md new file mode 100644 index 0000000000..176e2a1d2f --- /dev/null +++ b/docs/spec-agents-arena.md @@ -0,0 +1,279 @@ +# Spec: Agents Arena (`/agents/arena`) + +## What it is + +A live leaderboard showing which AI coding agents and LLMs are winning the +developer mindshare race right now. Powered by real-time sentiment + mention +volume, crowned category winners, and a competitive energy that makes it worth +checking daily and sharing when your tool is on top. + +The hook: **"Who's winning the race?"** — not a boring sentiment dashboard, but +a live arena where tools compete for developer love, and the D-Index tells you +who's ahead. + +--- + +## URL Structure + +``` +/agents/arena → main page (Coding Agents tab default) +/agents/arena?tab=llms → LLMs tab +/agents/arena/[slug] → per-tool detail page +``` + +--- + +## Entity Groups + +Each tab maps to a `groupId` on the `sentimentTimeSeries` API. + +**Tab: Coding Agents** (`groupId: "coding-agents"`) +``` +cursor, copilot, windsurf, cline, claude_code, +codex, aider, opencode, antigravity, kilocode +``` + +**Tab: LLMs** (`groupId: "llms"`) +``` +claude_sonnet, claude_opus, gpt_5, gpt_codex, +deepseek, gemini, llama, qwen, kimi +``` + +--- + +## The D-Index + +The D-Index is daily.dev's proprietary developer mindshare score. It measures +how much **positive attention** a tool is generating right now — combining raw +mention volume with sentiment polarity. + +### Formulas + +```typescript +// Raw from API: sentimentScore ∈ [-1, +1], volume = mention count per window + +// Headline number — unbounded, rewards viral moments +dIndex = volume_24h × ((sentimentScore + 1) / 2) + +// Love score — 0-100, used for bar fill and "Most Loved" crown +sentimentDisplay = Math.round(((sentimentScore + 1) / 2) × 100) + +// Momentum — % change vs same 24h window yesterday +momentum = (dIndex_now - dIndex_24h_ago) / dIndex_24h_ago × 100 + +// Controversy — peaks at high volume + perfectly split sentiment +controversyScore = volume_24h × (1 - Math.abs(sentimentScore)) +``` + +### Why unbounded + +The D-Index has no ceiling. When a tool ships something huge or goes viral, +the number should explode — e.g. DeepSeek at peak: `180,000 × 0.62 = 111,600`. +This is intentional. Dramatic spikes are the point. + +--- + +## Crown Categories + +Four spotlight titles awarded to the current leader in each category. +Evaluated on a **rolling 24h window** — crowns can change at any moment. + +| Crown | Awarded to | Minimum threshold | +|---|---|---| +| 👑 Most Loved | Highest `sentimentDisplay` | 100 mentions/24h | +| 🚀 Fastest Rising | Highest positive `momentum` | 50 mentions/24h | +| 🔥 Most Discussed | Highest raw `volume_24h` | — | +| ⚡ Most Controversial | Highest `controversyScore` | 200 mentions/24h | + +**Unclaimed crowns:** If no tool meets the minimum threshold for a category, +the card displays "Unclaimed" with a grayed crown — not hidden. This signals +the category is actively contested and creates anticipation. + +**Held for:** Each crown card shows how long the current holder has held it +(e.g. "Held for 31h") — adds drama and a reason to check back. + +--- + +## Page Layout + +### Header + +Sticky. Shows page title, live freshness indicator, and tab switcher. + +``` +⚔️ THE ARENA ● Live · 2m ago +Real-time developer sentiment · Powered by the D-Index + +[ Coding Agents ] [ LLMs ] +``` + +- Pulsing green dot when data is fresh (<5min) +- Dot turns amber + "stale" label if last update was >5min ago +- Auto-refreshes every 30s + +--- + +### Tier 1: The Crowns + +Four cards in a horizontal scroll row (mobile) or 4-column grid (desktop). +These are the shareable trophies — the reason companies check this page. + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ 👑 MOST LOVED │ │ 🚀 FASTEST │ │ 🔥 MOST │ │ ⚡ MOST │ +│ │ │ RISING │ │ DISCUSSED │ │ CONTROVERSIAL│ +│ [Tool Logo] │ │ [Tool Logo] │ │ [Tool Logo] │ │ [Tool Logo] │ +│ Cursor │ │ Claude Code │ │ DeepSeek │ │ Copilot │ +│ │ │ │ │ │ │ │ +│ 94 / 100 │ │ ▲ +187% │ │ 111,600 D-Index │ │ Split: 51/49 │ +│ Held for 31h │ │ Held for 4h │ │ Held for 12h │ │ Held for 2h │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +Each crown card: +- Crown emoji + category label +- Tool logo + name +- The stat that won it the crown (different per category) +- "Held for Xh" timestamp +- Subtle glow border in the tool's brand color +- Click → `/agents/arena/[slug]` + +--- + +### Tier 2: Full Rankings + +Sorted by D-Index descending. Rank positions animate when order changes. + +``` + # Tool D-Index Sentiment Momentum 24h Vol +────────────────────────────────────────────────────────────────── + 1 🥇 Cursor 2,088 ████░ 87 ▲ +12% 2.4k + ▁▂▃▄▅▆▇▇▆▅ +────────────────────────────────────────────────────────────────── + 2 🥈 Claude Code 1,547 ███░░ 71 ▲ +5% 1.8k + ▁▁▂▃▄▄▅▆▇▇ +────────────────────────────────────────────────────────────────── + 3 🥉 Copilot 1,203 ███░░ 68 ━ 0% 1.5k + ▃▃▃▃▃▄▄▄▄▄ +────────────────────────────────────────────────────────────────── + 4 Windsurf 454 ██░░░ 51 ▼ -8% 890 + ▅▄▄▃▃▂▂▂▁▁ +────────────────────────────────────────────────────────────────── + +─── EMERGING ────────────────────────────────────────────────────── + Antigravity Not enough data yet < 50 mentions + Kilocode Not enough data yet +``` + +Each established row: +- Rank + medal (🥇🥈🥉 for top 3) +- Tool logo + name +- **D-Index** — the headline number, ticks live on update +- Sentiment bar (colored: green >70, amber 40–70, red <40) + numeric score +- Momentum delta with colored arrow (green/red/gray) +- 24h mention volume +- 7-point sparkline (7-day daily trend) + +Live update behavior: +- D-Index number briefly flashes green (up) or red (down) on change +- Row slides up/down with transition when rank order changes + +--- + +### Emerging Tools Section + +Tools below the minimum data threshold are shown at the bottom, separated by +a divider, with no D-Index score. As volume crosses the threshold, they +graduate into the main rankings with an animation. + +--- + +## Data Layer + +### Primary query (on mount + every 30s) + +```graphql +query ArenaData($groupId: ID!, $lookback: String!) { + sentimentTimeSeries( + resolution: QUARTER_HOUR + groupId: $groupId + lookback: $lookback + ) { + start + resolutionSeconds + entities { + nodes { + entity + timestamps + scores # sentiment [-1, +1] + volume # mention count + } + } + } +} +``` + +- Called twice on mount: once per tab group (`coding-agents`, `llms`) +- `lookback: "48h"` to have enough history for momentum calculation +- Refetched every 30s via TanStack Query `refetchInterval` + +From the response we derive: +- **Current window** (latest data point) → `dIndex`, `sentimentDisplay` +- **Previous window** (latest point from 24h prior) → `momentum` +- **Last 7 daily buckets** → sparkline data + +### Highlights query (lazy, on tool card click) + +```graphql +query ToolHighlights($entity: String!, $after: String) { + sentimentHighlights(entity: $entity, first: 20, after: $after) { + items { + url + text + author { handle avatarUrl } + metrics { likeCount retweetCount impressionCount } + createdAt + sentiments { score highlightScore } + } + cursor + } +} +``` + +Only fired when navigating to `/agents/arena/[slug]`. + +--- + +## Real-Time Behavior + +| Event | Behavior | +|---|---| +| Data refresh (every 30s) | D-Index values update; changed values flash green/red | +| Rank position change | Row slides up/down with CSS transition | +| New crown holder | Crown card updates; brief confetti burst on the card | +| Tool graduates from Emerging | Animates into main rankings with highlight flash | +| Stale data (>5min) | Live dot → amber; "Last updated" text goes muted | +| API down / error | Show last known data with timestamp; no error state shown | + +--- + +## Per-Tool Detail Page: `/agents/arena/[slug]` + +High-level shape (full spec separate): + +- **Hero:** tool name, logo, current crown(s) if held +- **Live stats:** D-Index, sentiment score, current rank, 24h volume +- **Sentiment chart:** 48h time series (line chart, 15min resolution) +- **Top highlights:** paginated list of top tweets/posts, sorted by + `highlightScore`, showing author, text, engagement metrics +- **Back:** breadcrumb to `/agents/arena` + +--- + +## Open Questions + +- **Slug mapping:** The API uses entity keys like `claude_code`. We need a + mapping from slug (URL-safe) to entity key and display name/logo. + Define this as a static config file. +- **Crown sharing:** OG image generation per crown card deferred to future + iteration. diff --git a/packages/shared/src/features/agents/arena/ArenaAnimatedCounter.tsx b/packages/shared/src/features/agents/arena/ArenaAnimatedCounter.tsx new file mode 100644 index 0000000000..680343f003 --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaAnimatedCounter.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; + +interface ArenaAnimatedCounterProps { + value: number; + format?: (n: number) => string; + className?: string; + duration?: number; +} + +export const ArenaAnimatedCounter = ({ + value, + format, + className, + duration = 800, +}: ArenaAnimatedCounterProps): ReactElement => { + const [display, setDisplay] = useState(value); + const prevRef = useRef(value); + const frameRef = useRef(0); + + useEffect(() => { + const from = prevRef.current; + const to = value; + prevRef.current = value; + + if (from === to) { + return undefined; + } + + const startTime = performance.now(); + const animate = (now: number) => { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + // ease-out cubic + const eased = 1 - (1 - progress) * (1 - progress) * (1 - progress); + const current = Math.round(from + (to - from) * eased); + setDisplay(current); + + if (progress < 1) { + frameRef.current = requestAnimationFrame(animate); + } + }; + + frameRef.current = requestAnimationFrame(animate); + + return () => { + cancelAnimationFrame(frameRef.current); + }; + }, [value, duration]); + + const formatted = format ? format(display) : display.toLocaleString(); + + return {formatted}; +}; diff --git a/packages/shared/src/features/agents/arena/ArenaCrownCards.tsx b/packages/shared/src/features/agents/arena/ArenaCrownCards.tsx new file mode 100644 index 0000000000..611a71b20a --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaCrownCards.tsx @@ -0,0 +1,124 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { CrownData } from './types'; + +interface ArenaCrownCardsProps { + crowns: CrownData[]; + loading?: boolean; +} + +const Placeholder = ({ className }: { className?: string }): ReactElement => ( +
+); + +const CrownCard = ({ + crown, + loading, +}: { + crown: CrownData; + loading?: boolean; +}): ReactElement => ( +
+ {/* Animated gradient glow that breathes */} + {!loading && crown.entity?.brandColor && ( + <> +
+
+ + )} + + {/* Crown emoji + label */} +
+ + {crown.emoji} + + + {crown.label} + +
+ + {/* Tool info */} +
+ {loading ? ( + <> + + + + ) : ( + <> + {crown.entity?.name} + + {crown.entity?.name} + + + )} +
+ + {/* Stat line */} +
+ {loading ? ( + + ) : ( + + {crown.stat} + + )} +
+
+); + +export const ArenaCrownCards = ({ + crowns, + loading, +}: ArenaCrownCardsProps): ReactElement => ( +
+ {crowns.map((crown) => ( + + ))} +
+); diff --git a/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx b/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx new file mode 100644 index 0000000000..8c9d2cd133 --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx @@ -0,0 +1,233 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { SentimentAnnotation, SentimentHighlightItem } from './types'; +import { stripTcoLinks, formatTimeAgo } from './ArenaLiveTicker'; + +const decodeHtmlEntities = (text: string): string => { + const textarea = + typeof document !== 'undefined' && document.createElement('textarea'); + if (!textarea) { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } + textarea.innerHTML = text; + return textarea.value; +}; + +const getSentimentColor = (score: number): string => { + if (score > 0.2) { + return 'bg-accent-avocado-default'; + } + if (score < -0.2) { + return 'bg-accent-ketchup-default'; + } + return 'bg-text-quaternary'; +}; + +const Placeholder = ({ className }: { className?: string }): ReactElement => ( +
+); + +const SentimentPill = ({ + sentiment, +}: { + sentiment: SentimentAnnotation; +}): ReactElement => { + return ( + + + {sentiment.entity.replace(/_/g, ' ')} + + ); +}; + +const AuthorAvatar = ({ + author, +}: { + author?: SentimentHighlightItem['author']; +}): ReactElement => { + if (author?.avatarUrl) { + return ( + {author.name + ); + } + + const initials = (author?.name ?? author?.handle ?? '?') + .split(' ') + .map((w) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + return ( +
+ {initials} +
+ ); +}; + +const HighlightCard = ({ + item, +}: { + item: SentimentHighlightItem; +}): ReactElement => { + const cleanText = decodeHtmlEntities(stripTcoLinks(item.text)); + + return ( +
+ +
+ {/* Author info */} +
+ {item.author?.name && ( + + {item.author.name} + + )} + {item.author?.handle && ( + + @{item.author.handle} + + )} + + · {formatTimeAgo(item.createdAt)} + +
+ + {/* Full text */} +

+ {cleanText} +

+ + {/* Sentiment pills */} + {item.sentiments.length > 0 && ( +
+ {item.sentiments.map((s) => ( + + ))} +
+ )} +
+
+ ); +}; + +const PlaceholderCard = (): ReactElement => ( +
+ +
+
+ + +
+ +
+ + +
+
+
+); + +interface ArenaHighlightsFeedProps { + items: SentimentHighlightItem[]; + loading?: boolean; +} + +export const ArenaHighlightsFeed = ({ + items, + loading, +}: ArenaHighlightsFeedProps): ReactElement => { + const [visibleItems, setVisibleItems] = useState( + [], + ); + const [pendingItems, setPendingItems] = useState( + [], + ); + const initializedRef = useRef(false); + const scrollRef = useRef(null); + + useEffect(() => { + if (items.length === 0) { + return; + } + + // First load — show everything + if (!initializedRef.current) { + initializedRef.current = true; + setVisibleItems(items); + return; + } + + // Subsequent updates — find new items not in visible set + const visibleIds = new Set(visibleItems.map((i) => i.externalItemId)); + const newItems = items.filter((i) => !visibleIds.has(i.externalItemId)); + if (newItems.length > 0) { + setPendingItems((prev) => [...newItems, ...prev]); + } + }, [items]); // eslint-disable-line react-hooks/exhaustive-deps + + const showPending = (): void => { + setVisibleItems((prev) => [...pendingItems, ...prev]); + setPendingItems([]); + scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+ {/* eslint-disable-next-line react/no-danger */} +