Files
new-api/web/default/src/features/rankings/lib/mock-rankings.ts
T
8b2b03d276 feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)
* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges

Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage.

Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx.

Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark).

Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi.

* 🎨 fix(web): align segmented controls with theme radius tokens

- Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart
  toolbars with radius-md so the active state follows --radius when users
  change Radius in Theme Settings.
- Use nested radii consistent with TabsList/TabsTrigger: outer
  rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px))
  so the track and active thumb stay concentric at small scales (e.g.
  0.3rem) instead of a squared “focus” block inside a rounded shell.
- Apply the same pattern to pricing SegmentedControl and the segmented
  groups in consumption-distribution-chart, model-charts, and user-charts.

Verified: bun run typecheck (web/default)

*  feat(pricing): enrich model details with uptime sparkline and API documentation

Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with
per-day tooltips, surface it in a status row under quick stats and in the
per-group performance table, and extend mock data so uptime series are stable
and optionally scoped by group.

Introduce an API tab with Shiki-highlighted code samples (cURL, Python,
TypeScript, JavaScript), endpoint-type switching, authentication guidance, a
supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer
vendor, tokenizer, license, and data-retention hints for a provider & data
privacy card on the Overview tab (capabilities/modalities stay with model
identity; rate limits stay with the API tab).

Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi.

* 🏆 feat(rankings): add comprehensive rankings dashboard

Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending.

Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales.

* fix(theme): correct theme preset selection state

- update Base UI Radio selectors to use data-checked/data-unchecked states.
- fix unchecked theme options still showing selected indicators.
- isolate the default theme preview tokens to prevent preset changes from leaking into it.

* fix(setup): correct usage mode radio state

- use Base UI data-checked/data-unchecked states for RadioGroup styling.
- hide radio indicators when options are unchecked to avoid setup page display issues.
- drive usage mode card and icon selection styles from Base UI state.

* fix(auth): submit sign-in and sign-up forms

* 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization

Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`)
and reinstall full component registry with `--overwrite`, including Hugeicons-backed
widgets and newly added registry components.

- Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies,
  preset UI from config drawer); rely on official semantic CSS tokens + light/dark only.
- Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and
  `@theme inline` mappings (plus skeleton token vars for existing shimmer usage).
- Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`;
  clarify scroll-lock override comment.

Application compatibility:
- Keep minimal shims where app code diverged from official APIs (popover collision props,
  combobox legacy `options` callers, Spinner prop typing).
- Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI
  semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.)

Tooling / docs / build:
- Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`.
- Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack.
- Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.)

Verified: `bun run typecheck` passes.

Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere;
not addressed in this change.

* 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters

- Refactor DataTableToolbar to a single wrapping flex row with a
  right-aligned action cluster (Reset / Search / View / Expand) for a
  cleaner Ant Design Pro–style filter bar; remove the dedicated stats row
  and the toolbar `stats` prop.
- Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive-
  data visibility toggle into the page header via CommonLogsHeaderActions
  and SectionPageLayout.Actions so the toolbar stays focused on filters.
- Slim CommonLogsFilterBar props (no stats / preActions eye control).
- Improve CompactDateTimeRangePicker: show minute-precision labels on the
  trigger (seconds omitted; aligns with datetime-local inputs); widen the
  trigger on sm+ breakpoints so the full range is visible without truncation;
  apply the same width in task logs filters.
- Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon.
- Earlier layout tweak: extra top padding on SectionPageLayout scroll
  content so control focus rings are not clipped by overflow.

* feat(web/default): Base UI migration and component foundation

Migrate from Radix UI to Base UI, rewrite core UI primitives,
update dependencies (recharts, date-fns, next-themes), add
shadcn agent skill documentation, and refresh AI element components.

This is the foundational work from the v2/localmain lineage that
was not covered by the individual feature commits above.

---------

Co-authored-by: t0ng7u <dev@aiass.cc>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
2026-05-06 12:39:36 +08:00

1049 lines
29 KiB
TypeScript
Vendored

import {
hashStringToSeed,
randomInRange,
randomIntInRange,
seededRandom,
} from '@/features/pricing/lib/seed'
import type {
AppCategory,
AppListing,
CategorySection,
ModelHistoryPoint,
ModelHistorySeries,
ModelRanking,
NewModelEntry,
RankingCategory,
RankingCategoryId,
RankingMover,
RankingPeriod,
RankingsSnapshot,
VendorRanking,
VendorSharePoint,
VendorShareSeries,
} from '../types'
// ----------------------------------------------------------------------------
// Catalogue: categories + canonical model & app fixtures
// ----------------------------------------------------------------------------
//
// All ranking data is derived from these fixtures plus a deterministic PRNG
// seeded by `${period}:${category}`. Every call with the same arguments
// returns the same numbers, while different (period, category) pairs render
// visibly distinct data. When the backend ships real analytics, these
// fixtures stay only as fallbacks.
export const RANKING_CATEGORIES: RankingCategory[] = [
{
id: 'all',
label: 'All categories',
description: 'Aggregate traffic across every category',
},
{
id: 'programming',
label: 'Programming',
description: 'Code generation, refactoring, autocomplete',
},
{
id: 'roleplay',
label: 'Roleplay',
description: 'Character chat, storytelling, persona',
},
{
id: 'marketing',
label: 'Marketing',
description: 'Copywriting, ad creative, SEO',
},
{
id: 'translation',
label: 'Translation',
description: 'Multilingual translation and localisation',
},
{
id: 'science',
label: 'Science',
description: 'Research, analysis, scientific reasoning',
},
{
id: 'finance',
label: 'Finance',
description: 'Trading insights, accounting, advisory',
},
{
id: 'health',
label: 'Health',
description: 'Medical Q&A, mental health support',
},
{
id: 'legal',
label: 'Legal',
description: 'Contract review, compliance, summarisation',
},
{
id: 'education',
label: 'Education',
description: 'Tutoring, learning aids, assessment',
},
{
id: 'productivity',
label: 'Productivity',
description: 'Email, summarisation, knowledge work',
},
{
id: 'multimodal',
label: 'Multimodal',
description: 'Vision, image / video, document chat',
},
]
type ModelFixture = {
name: string
vendor: string
vendor_icon: string
release_date: string
/** Categories this model commonly serves. First entry is the primary. */
categories: RankingCategoryId[]
/** Relative popularity weight (0..1). */
weight: number
}
const MODEL_FIXTURES: ModelFixture[] = [
{
name: 'gpt-5',
vendor: 'OpenAI',
vendor_icon: 'OpenAI.Color',
release_date: '2025-10-12',
categories: ['programming', 'productivity', 'science'],
weight: 1.0,
},
{
name: 'claude-sonnet-4-5',
vendor: 'Anthropic',
vendor_icon: 'Claude.Color',
release_date: '2025-09-08',
categories: ['programming', 'productivity', 'legal'],
weight: 0.96,
},
{
name: 'gemini-2.5-pro',
vendor: 'Google',
vendor_icon: 'Gemini.Color',
release_date: '2025-06-15',
categories: ['multimodal', 'science', 'education'],
weight: 0.88,
},
{
name: 'deepseek-v3.2',
vendor: 'DeepSeek',
vendor_icon: 'DeepSeek.Color',
release_date: '2025-08-22',
categories: ['programming', 'science'],
weight: 0.84,
},
{
name: 'gpt-5-mini',
vendor: 'OpenAI',
vendor_icon: 'OpenAI.Color',
release_date: '2025-10-12',
categories: ['productivity', 'roleplay', 'translation'],
weight: 0.78,
},
{
name: 'claude-opus-4-5',
vendor: 'Anthropic',
vendor_icon: 'Claude.Color',
release_date: '2025-08-04',
categories: ['legal', 'science', 'finance'],
weight: 0.7,
},
{
name: 'qwen3-235b-a22b',
vendor: 'Alibaba',
vendor_icon: 'Qwen.Color',
release_date: '2025-05-30',
categories: ['programming', 'translation', 'science'],
weight: 0.66,
},
{
name: 'grok-4',
vendor: 'xAI',
vendor_icon: 'XAI',
release_date: '2025-04-18',
categories: ['roleplay', 'science', 'marketing'],
weight: 0.62,
},
{
name: 'llama-4-maverick',
vendor: 'Meta',
vendor_icon: 'Meta.Color',
release_date: '2025-04-05',
categories: ['programming', 'productivity'],
weight: 0.58,
},
{
name: 'kimi-k2',
vendor: 'Moonshot',
vendor_icon: 'Moonshot',
release_date: '2025-07-19',
categories: ['productivity', 'translation'],
weight: 0.55,
},
{
name: 'glm-4.6',
vendor: 'Zhipu',
vendor_icon: 'Zhipu.Color',
release_date: '2025-09-26',
categories: ['programming', 'productivity'],
weight: 0.52,
},
{
name: 'gemini-2.5-flash',
vendor: 'Google',
vendor_icon: 'Gemini.Color',
release_date: '2025-06-15',
categories: ['productivity', 'translation', 'multimodal'],
weight: 0.49,
},
{
name: 'mistral-large-3',
vendor: 'Mistral',
vendor_icon: 'Mistral.Color',
release_date: '2025-03-12',
categories: ['programming', 'finance'],
weight: 0.46,
},
{
name: 'doubao-1.6-pro',
vendor: 'ByteDance',
vendor_icon: 'Doubao.Color',
release_date: '2025-07-02',
categories: ['marketing', 'roleplay'],
weight: 0.44,
},
{
name: 'hunyuan-turbos',
vendor: 'Tencent',
vendor_icon: 'Hunyuan.Color',
release_date: '2025-05-08',
categories: ['productivity', 'translation'],
weight: 0.4,
},
{
name: 'gpt-image-2',
vendor: 'OpenAI',
vendor_icon: 'OpenAI.Color',
release_date: '2025-06-04',
categories: ['multimodal', 'marketing'],
weight: 0.38,
},
{
name: 'sora-2',
vendor: 'OpenAI',
vendor_icon: 'OpenAI.Color',
release_date: '2025-09-30',
categories: ['multimodal', 'marketing'],
weight: 0.34,
},
{
name: 'veo-3',
vendor: 'Google',
vendor_icon: 'Gemini.Color',
release_date: '2025-08-15',
categories: ['multimodal', 'marketing'],
weight: 0.31,
},
{
name: 'qwen3-vl-plus',
vendor: 'Alibaba',
vendor_icon: 'Qwen.Color',
release_date: '2025-06-20',
categories: ['multimodal', 'education'],
weight: 0.3,
},
{
name: 'minimax-m2',
vendor: 'MiniMax',
vendor_icon: 'Minimax.Color',
release_date: '2025-07-25',
categories: ['roleplay', 'translation'],
weight: 0.28,
},
{
name: 'cohere-command-r-plus',
vendor: 'Cohere',
vendor_icon: 'Cohere.Color',
release_date: '2024-11-10',
categories: ['marketing', 'productivity'],
weight: 0.26,
},
{
name: 'ernie-x1-turbo',
vendor: 'Baidu',
vendor_icon: 'Baidu.Color',
release_date: '2025-04-30',
categories: ['translation', 'productivity'],
weight: 0.22,
},
]
type AppFixture = {
name: string
description: string
category: AppCategory
url?: string
weight: number
/** Bias toward these models (model_name). */
prefers: string[]
}
const APP_FIXTURES: AppFixture[] = [
{
name: 'Cline',
description: 'Autonomous coding agent inside the IDE',
category: 'Coding',
url: 'https://cline.bot',
weight: 1.0,
prefers: ['claude-sonnet-4-5', 'gpt-5'],
},
{
name: 'Roo Code',
description: 'AI agent for VS Code with multi-step planning',
category: 'Coding',
url: 'https://roocode.com',
weight: 0.9,
prefers: ['claude-sonnet-4-5', 'deepseek-v3.2'],
},
{
name: 'Cursor',
description: 'Editor with built-in AI for code generation',
category: 'Coding',
url: 'https://cursor.com',
weight: 0.85,
prefers: ['gpt-5', 'claude-sonnet-4-5'],
},
{
name: 'Continue',
description: 'Open-source AI code assistant for editors',
category: 'Coding',
url: 'https://continue.dev',
weight: 0.62,
prefers: ['deepseek-v3.2', 'qwen3-235b-a22b'],
},
{
name: 'Aider',
description: 'Pair-programming in your terminal',
category: 'Coding',
url: 'https://aider.chat',
weight: 0.46,
prefers: ['claude-sonnet-4-5', 'gpt-5'],
},
{
name: 'Open WebUI',
description: 'Self-hosted ChatGPT-like web interface',
category: 'Chat',
url: 'https://openwebui.com',
weight: 0.74,
prefers: ['gpt-5-mini', 'qwen3-235b-a22b'],
},
{
name: 'LibreChat',
description: 'Open-source multi-model chat platform',
category: 'Chat',
url: 'https://librechat.ai',
weight: 0.6,
prefers: ['gpt-5-mini', 'gemini-2.5-flash'],
},
{
name: 'Lobe Chat',
description: 'Modern open-source chat UI with plugins',
category: 'Chat',
url: 'https://lobehub.com',
weight: 0.58,
prefers: ['gpt-5-mini', 'claude-sonnet-4-5'],
},
{
name: 'NextChat',
description: 'Cross-platform private ChatGPT client',
category: 'Chat',
url: 'https://nextchat.dev',
weight: 0.4,
prefers: ['gpt-5-mini', 'gemini-2.5-flash'],
},
{
name: 'TypingMind',
description: 'Better UI for ChatGPT and Claude',
category: 'Chat',
url: 'https://typingmind.com',
weight: 0.34,
prefers: ['gpt-5', 'claude-sonnet-4-5'],
},
{
name: 'SillyTavern',
description: 'Roleplay frontend for chat models',
category: 'Roleplay',
url: 'https://sillytavernai.com',
weight: 0.7,
prefers: ['claude-opus-4-5', 'minimax-m2'],
},
{
name: 'Janitor AI',
description: 'Roleplay chat with custom characters',
category: 'Roleplay',
url: 'https://janitorai.com',
weight: 0.55,
prefers: ['minimax-m2', 'doubao-1.6-pro'],
},
{
name: 'Notion AI',
description: 'AI features inside Notion docs',
category: 'Productivity',
url: 'https://notion.so',
weight: 0.62,
prefers: ['gpt-5', 'claude-sonnet-4-5'],
},
{
name: 'Reflect',
description: 'Personal AI knowledge assistant',
category: 'Productivity',
url: 'https://reflect.app',
weight: 0.36,
prefers: ['gpt-5-mini', 'claude-sonnet-4-5'],
},
{
name: 'Mem',
description: 'AI-first note-taking app',
category: 'Productivity',
url: 'https://mem.ai',
weight: 0.32,
prefers: ['gpt-5-mini'],
},
{
name: 'Khanmigo',
description: 'Tutor for Khan Academy learners',
category: 'Education',
url: 'https://khanmigo.ai',
weight: 0.48,
prefers: ['gpt-5', 'claude-sonnet-4-5'],
},
{
name: 'Quizlet AI',
description: 'Personalised study & flashcards',
category: 'Education',
url: 'https://quizlet.com',
weight: 0.36,
prefers: ['gemini-2.5-flash'],
},
{
name: 'Perplexity',
description: 'Conversational answer engine',
category: 'Research',
url: 'https://perplexity.ai',
weight: 0.78,
prefers: ['gpt-5', 'claude-sonnet-4-5'],
},
{
name: 'Elicit',
description: 'AI research assistant for papers',
category: 'Research',
url: 'https://elicit.com',
weight: 0.42,
prefers: ['claude-opus-4-5', 'gemini-2.5-pro'],
},
{
name: 'Jasper',
description: 'Marketing copywriting platform',
category: 'Marketing',
url: 'https://jasper.ai',
weight: 0.5,
prefers: ['gpt-5', 'claude-sonnet-4-5'],
},
{
name: 'Copy.ai',
description: 'AI sales & marketing automation',
category: 'Marketing',
url: 'https://copy.ai',
weight: 0.4,
prefers: ['gpt-5-mini'],
},
{
name: 'DeepL Write',
description: 'AI rewriting & translation',
category: 'Translation',
url: 'https://deepl.com',
weight: 0.36,
prefers: ['gpt-5-mini', 'qwen3-235b-a22b'],
},
{
name: 'Wordtune',
description: 'AI rewriting & paraphrasing',
category: 'Translation',
url: 'https://wordtune.com',
weight: 0.3,
prefers: ['gpt-5-mini'],
},
{
name: 'Harvey',
description: 'AI assistant for law firms',
category: 'Other',
url: 'https://harvey.ai',
weight: 0.42,
prefers: ['claude-opus-4-5', 'gpt-5'],
},
{
name: 'Hippocratic',
description: 'Healthcare-focused AI agents',
category: 'Health',
url: 'https://hippocratic.ai',
weight: 0.32,
prefers: ['claude-opus-4-5'],
},
{
name: 'Cleo',
description: 'AI personal finance assistant',
category: 'Finance',
url: 'https://meetcleo.com',
weight: 0.28,
prefers: ['gpt-5-mini'],
},
]
// ----------------------------------------------------------------------------
// PRNG seeding helpers
// ----------------------------------------------------------------------------
const PERIOD_FACTOR: Record<RankingPeriod, number> = {
today: 0.04,
week: 0.25,
month: 1.0,
year: 11.5,
all: 38.0,
}
function periodSeed(
period: RankingPeriod,
category: RankingCategoryId
): number {
return hashStringToSeed(`rankings:${period}:${category}`)
}
/** Pick a previous_rank for a model that's currently at `rank`. */
function makePreviousRank(rand: () => number, rank: number, total: number) {
if (rand() < 0.08) return undefined
const delta = randomIntInRange(rand, -3, 3)
const prev = Math.max(1, Math.min(total + 4, rank + delta))
if (prev === rank) return undefined
return prev
}
// ----------------------------------------------------------------------------
// Leaderboard builders
// ----------------------------------------------------------------------------
function buildModelRankings(
period: RankingPeriod,
category: RankingCategoryId
): ModelRanking[] {
const seed = periodSeed(period, category)
const periodFactor = PERIOD_FACTOR[period]
const filtered =
category === 'all'
? MODEL_FIXTURES
: MODEL_FIXTURES.filter((m) => m.categories.includes(category))
// Slight per-call jitter on weights so the leaderboard re-orders a bit
// between periods/categories.
const ranked = filtered
.map((m) => ({
fixture: m,
score:
m.weight *
(0.85 + seededRandom(seed ^ hashStringToSeed(m.name))() * 0.4),
}))
.sort((a, b) => b.score - a.score)
.slice(0, 20)
if (ranked.length === 0) return []
const totalScore = ranked.reduce((s, r) => s + r.score, 0)
const baseTokens = 240_000_000 * periodFactor
return ranked.map(({ fixture, score }, idx) => {
const rowSeed = seed ^ hashStringToSeed(fixture.name)
const rowRand = seededRandom(rowSeed)
const share = score / totalScore
const totalTokens = Math.round(baseTokens * share * (0.9 + rowRand() * 0.2))
const growth = randomInRange(seededRandom(rowSeed ^ 0x11), -22, 96)
return {
rank: idx + 1,
previous_rank: makePreviousRank(
seededRandom(rowSeed ^ 0x22),
idx + 1,
ranked.length
),
model_name: fixture.name,
vendor: fixture.vendor,
vendor_icon: fixture.vendor_icon,
category: fixture.categories[0],
total_tokens: totalTokens,
share,
growth_pct: Math.round(growth * 10) / 10,
}
})
}
function buildAppListings(
period: RankingPeriod,
category: RankingCategoryId,
models: ModelRanking[]
): AppListing[] {
const seed = periodSeed(period, category) ^ 0xa11
const periodFactor = PERIOD_FACTOR[period]
// Map "all" category to all apps; otherwise filter by a soft mapping. We
// keep the list large and let weights distribute naturally.
const filtered = APP_FIXTURES.filter((app) => {
if (category === 'all') return true
if (category === 'programming') return app.category === 'Coding'
if (category === 'roleplay') return app.category === 'Roleplay'
if (category === 'marketing') return app.category === 'Marketing'
if (category === 'translation') return app.category === 'Translation'
if (category === 'education') return app.category === 'Education'
if (category === 'productivity')
return app.category === 'Productivity' || app.category === 'Chat'
if (category === 'science') return app.category === 'Research'
if (category === 'health') return app.category === 'Health'
if (category === 'finance') return app.category === 'Finance'
if (category === 'multimodal')
return ['Creative', 'Marketing'].includes(app.category)
return true
})
const ranked = filtered
.map((app) => ({
app,
score:
app.weight *
(0.85 + seededRandom(seed ^ hashStringToSeed(app.name))() * 0.4),
}))
.sort((a, b) => b.score - a.score)
if (ranked.length === 0) return []
const totalScore = ranked.reduce((s, r) => s + r.score, 0)
const baseTokens = 84_000_000 * periodFactor
const modelNames = new Set(models.map((m) => m.model_name))
return ranked.slice(0, 14).map(({ app, score }, idx) => {
const rowSeed = seed ^ hashStringToSeed(app.name)
const share = score / totalScore
const totalTokens = Math.round(
baseTokens * share * (0.9 + seededRandom(rowSeed)() * 0.25)
)
const growth = randomInRange(seededRandom(rowSeed ^ 0xab), -28, 130)
const topModel =
app.prefers.find((m) => modelNames.has(m)) ??
app.prefers[0] ??
models[0]?.model_name ??
'gpt-5'
return {
rank: idx + 1,
previous_rank: makePreviousRank(
seededRandom(rowSeed ^ 0xcd),
idx + 1,
ranked.length
),
name: app.name,
description: app.description,
category: app.category,
url: app.url,
total_tokens: totalTokens,
growth_pct: Math.round(growth * 10) / 10,
top_model: topModel,
initial: app.name.charAt(0).toUpperCase(),
}
})
}
function buildVendorRankings(models: ModelRanking[]): VendorRanking[] {
if (models.length === 0) return []
const totals = new Map<
string,
{
tokens: number
icon?: string
count: number
growthSum: number
topModel: { name: string; tokens: number }
}
>()
for (const m of models) {
const cur = totals.get(m.vendor)
if (!cur) {
totals.set(m.vendor, {
tokens: m.total_tokens,
icon: m.vendor_icon,
count: 1,
growthSum: m.growth_pct,
topModel: { name: m.model_name, tokens: m.total_tokens },
})
} else {
cur.tokens += m.total_tokens
cur.count += 1
cur.growthSum += m.growth_pct
if (m.total_tokens > cur.topModel.tokens) {
cur.topModel = { name: m.model_name, tokens: m.total_tokens }
}
}
}
const grand = [...totals.values()].reduce((s, v) => s + v.tokens, 0)
const sorted = [...totals.entries()]
.map(([vendor, v]) => ({
vendor,
total_tokens: v.tokens,
vendor_icon: v.icon,
models_count: v.count,
top_model: v.topModel.name,
share: v.tokens / Math.max(grand, 1),
growth_pct: Math.round((v.growthSum / v.count) * 10) / 10,
}))
.sort((a, b) => b.total_tokens - a.total_tokens)
return sorted.map((row, idx) => ({ rank: idx + 1, ...row }))
}
function buildMovers(models: ModelRanking[]): {
movers: RankingMover[]
droppers: RankingMover[]
} {
const withDelta = models
.filter((m) => m.previous_rank !== undefined)
.map<RankingMover>((m) => ({
model_name: m.model_name,
vendor: m.vendor,
vendor_icon: m.vendor_icon,
current_rank: m.rank,
rank_delta: (m.previous_rank ?? m.rank) - m.rank,
growth_pct: m.growth_pct,
}))
const movers = [...withDelta]
.filter((x) => x.rank_delta > 0)
.sort((a, b) => b.rank_delta - a.rank_delta || b.growth_pct - a.growth_pct)
.slice(0, 5)
const droppers = [...withDelta]
.filter((x) => x.rank_delta < 0)
.sort((a, b) => a.rank_delta - b.rank_delta || a.growth_pct - b.growth_pct)
.slice(0, 5)
return { movers, droppers }
}
function buildNewModels(period: RankingPeriod): NewModelEntry[] {
const seed = periodSeed(period, 'all') ^ 0xfa11
const rand = seededRandom(seed)
// "New" = released within the last 90 days for shorter periods, last 12
// months for "year/all"
const cutoffDays = period === 'today' || period === 'week' ? 90 : 365
const cutoffMs = Date.now() - cutoffDays * 86_400_000
return MODEL_FIXTURES.filter((m) => Date.parse(m.release_date) >= cutoffMs)
.slice()
.sort(
(a, b) =>
Date.parse(b.release_date) - Date.parse(a.release_date) ||
b.weight - a.weight
)
.slice(0, 6)
.map((m) => ({
model_name: m.name,
vendor: m.vendor,
vendor_icon: m.vendor_icon,
category: m.categories[0],
release_date: m.release_date,
total_tokens: Math.round(
220_000_000 * m.weight * PERIOD_FACTOR[period] * (0.85 + rand() * 0.3)
),
growth_pct: Math.round(randomInRange(rand, 35, 220) * 10) / 10,
}))
}
// ----------------------------------------------------------------------------
// History (stacked bar / 100% stacked area) builders
// ----------------------------------------------------------------------------
//
// These produce a longer time-series than the leaderboard sparklines so the
// charts can render a recognisable growth story across 30+ buckets. Bucket
// granularity scales with the active period.
const HISTORY_BUCKETS: Record<RankingPeriod, number> = {
today: 24, // hourly
week: 21, // 3 weeks of daily
month: 30, // ~30 days
year: 52, // ~1 year of weekly
all: 78, // ~18 months of weekly
}
/** Cap stacked series so the chart legend / colour palette stays legible. */
const HISTORY_TOP_MODELS = 18
const HISTORY_TOP_VENDORS = 12
const OTHERS_LABEL = 'Others'
function bucketStepMs(period: RankingPeriod): number {
if (period === 'today') return 60 * 60 * 1000
if (period === 'week' || period === 'month') return 24 * 60 * 60 * 1000
return 7 * 24 * 60 * 60 * 1000
}
function formatBucketLabel(date: Date, period: RankingPeriod): string {
if (period === 'today') {
return `${String(date.getHours()).padStart(2, '0')}:00`
}
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}
/**
* Smooth ramp-up profile in [0..1] for a model that launched at
* `releaseTs` and is observed at `bucketTs`. Models launched after the
* bucket return 0; long-established models return 1. The S-curve gives a
* natural ramp during the first ~6 weeks after launch.
*/
function rampWeight(bucketTs: number, releaseTs: number): number {
if (!Number.isFinite(releaseTs)) return 1
const ageMs = bucketTs - releaseTs
if (ageMs <= 0) return 0
const sixWeeks = 6 * 7 * 24 * 60 * 60 * 1000
if (ageMs >= sixWeeks) return 1
const t = ageMs / sixWeeks
return 1 - Math.pow(1 - t, 3)
}
function buildModelsHistory(
period: RankingPeriod,
models: ModelRanking[]
): ModelHistorySeries {
const buckets = HISTORY_BUCKETS[period]
if (buckets === 0 || models.length === 0) {
return { points: [], models: [], buckets: 0 }
}
const stepMs = bucketStepMs(period)
const now = Date.now()
const seed = periodSeed(period, 'all') ^ 0x71_57_07_4d
const top = models.slice(0, Math.min(models.length, HISTORY_TOP_MODELS))
const points: ModelHistoryPoint[] = []
const totals = new Map<string, number>()
for (const model of top) {
const releaseFixture = MODEL_FIXTURES.find(
(m) => m.name === model.model_name
)
const releaseTs = releaseFixture
? Date.parse(releaseFixture.release_date)
: Number.NaN
const modelSeed = seed ^ hashStringToSeed(`${model.model_name}:hist`)
const rand = seededRandom(modelSeed)
// Per-model average tokens per bucket so the area under the curve roughly
// matches `total_tokens` (the leaderboard summary).
const avgPerBucket = model.total_tokens / buckets
// Drift = how much the model has been growing across the visible window.
// Newer / faster-growing models (high growth_pct) show a steeper slope.
const drift = 0.4 + Math.min(2.4, model.growth_pct / 50)
// Shape factor — most weight near the end for growing models, more even
// for established ones.
const skew = 0.8 + rand() * 0.6
let modelTotal = 0
for (let i = buckets - 1; i >= 0; i--) {
const bucketTs = now - i * stepMs
const date = new Date(bucketTs)
const t = (buckets - 1 - i) / Math.max(1, buckets - 1)
const trendShape = Math.pow(t, 1.4 * skew) * drift + 0.4
const ramp = rampWeight(bucketTs, releaseTs)
const jitter = 0.78 + rand() * 0.45
const tokens = Math.max(
0,
Math.round(avgPerBucket * trendShape * ramp * jitter)
)
modelTotal += tokens
points.push({
ts: date.toISOString(),
label: formatBucketLabel(date, period),
model: model.model_name,
vendor: model.vendor,
tokens,
})
}
totals.set(model.model_name, modelTotal)
}
// Stable oldest → newest ordering.
points.sort((a, b) => a.ts.localeCompare(b.ts))
const ranked = top
.map((m) => ({
name: m.model_name,
vendor: m.vendor,
total: totals.get(m.model_name) ?? 0,
}))
.sort((a, b) => b.total - a.total)
return { points, models: ranked, buckets }
}
function buildVendorShareHistory(
history: ModelHistorySeries
): VendorShareSeries {
if (history.points.length === 0) {
return { points: [], vendors: [], buckets: 0 }
}
const byBucket = new Map<string, Map<string, number>>()
const labelByTs = new Map<string, string>()
for (const point of history.points) {
if (!byBucket.has(point.ts)) byBucket.set(point.ts, new Map())
if (!labelByTs.has(point.ts)) labelByTs.set(point.ts, point.label)
const map = byBucket.get(point.ts)!
map.set(point.vendor, (map.get(point.vendor) ?? 0) + point.tokens)
}
// Use the union of vendors observed across the window so the area chart
// has stable series even on buckets where a vendor has 0 tokens.
const vendorTotals = new Map<string, number>()
for (const [, vendorMap] of byBucket) {
for (const [vendor, tokens] of vendorMap) {
vendorTotals.set(vendor, (vendorTotals.get(vendor) ?? 0) + tokens)
}
}
const grand = [...vendorTotals.values()].reduce((s, v) => s + v, 0) || 1
const sortedVendors = [...vendorTotals.entries()].sort((a, b) => b[1] - a[1])
const topVendors = sortedVendors
.slice(0, HISTORY_TOP_VENDORS)
.map(([name]) => name)
const otherVendors = new Set(
sortedVendors.slice(HISTORY_TOP_VENDORS).map(([name]) => name)
)
const hasOthers = otherVendors.size > 0
const points: VendorSharePoint[] = []
const sortedTimestamps = [...byBucket.keys()].sort()
for (const ts of sortedTimestamps) {
const vendorMap = byBucket.get(ts)!
const label = labelByTs.get(ts) ?? ts
const totalAtBucket =
[...vendorMap.values()].reduce((s, v) => s + v, 0) || 1
for (const vendor of topVendors) {
const tokens = vendorMap.get(vendor) ?? 0
points.push({
ts,
label,
vendor,
share: tokens / totalAtBucket,
tokens,
})
}
if (hasOthers) {
let othersTokens = 0
for (const vendor of otherVendors) {
othersTokens += vendorMap.get(vendor) ?? 0
}
points.push({
ts,
label,
vendor: OTHERS_LABEL,
share: othersTokens / totalAtBucket,
tokens: othersTokens,
})
}
}
const vendors = topVendors
.map((name) => {
const total = vendorTotals.get(name) ?? 0
return { name, total, share: total / grand }
})
.sort((a, b) => b.total - a.total)
if (hasOthers) {
let othersTotal = 0
for (const vendor of otherVendors) {
othersTotal += vendorTotals.get(vendor) ?? 0
}
vendors.push({
name: OTHERS_LABEL,
total: othersTotal,
share: othersTotal / grand,
})
}
return { points, vendors, buckets: history.buckets }
}
/**
* Build a single per-category section. Used by `buildRankingsSnapshot` to
* eagerly compute every category section the page renders inline (rather
* than gating them behind a top-level filter).
*/
function buildCategorySection(
period: RankingPeriod,
category: RankingCategory
): CategorySection {
const models = buildModelRankings(period, category.id).slice(0, 12)
const models_history = buildModelsHistory(period, models)
const total_tokens = models.reduce((s, m) => s + m.total_tokens, 0)
return {
category: category.id,
label: category.label,
description: category.description,
models,
models_history,
total_tokens,
}
}
// ----------------------------------------------------------------------------
// Public entry point
// ----------------------------------------------------------------------------
/**
* Build a full leaderboard snapshot for the given period.
*
* The snapshot bundles the overall (all-categories) view used by the page
* header sections **and** an independent ranking unit for each non-`all`
* category — so the page can render every category inline instead of
* gating the data behind a category filter.
*/
export function buildRankingsSnapshot(period: RankingPeriod): RankingsSnapshot {
const models = buildModelRankings(period, 'all')
const apps = buildAppListings(period, 'all', models)
const vendors = buildVendorRankings(models)
const { movers, droppers } = buildMovers(models)
const new_models = buildNewModels(period)
const models_history = buildModelsHistory(period, models)
const vendor_share_history = buildVendorShareHistory(models_history)
const category_sections = RANKING_CATEGORIES.filter(
(c) => c.id !== 'all'
).map((c) => buildCategorySection(period, c))
return {
models,
apps,
vendors,
top_movers: movers,
top_droppers: droppers,
new_models,
models_history,
vendor_share_history,
category_sections,
}
}