From 6d8e8c48bb89f9a4b41afbd0392c9a25e7d95578 Mon Sep 17 00:00:00 2001 From: oleksiiovdiienko Date: Sat, 28 Mar 2026 20:01:02 +0200 Subject: [PATCH 1/2] feat(badges): implement daily, weekly, monthly badge support - Extend badge calculation logic in `lib/badges.ts` for daily, weekly, monthly periods - Add API route handlers for badge endpoints (`/daily`, `/weekly`, `/monthly`) - Update submission page and UI components to render the new badges - Add unit tests covering the new badge functionality --- app/api/badges/[metric]/[period]/route.ts | 126 +++++++++ app/submission/[id]/page.tsx | 28 ++ bun.lock | 5 + components/badge-embed-card.tsx | 111 ++++++++ lib/badges.test.ts | 87 +++++++ lib/badges.ts | 301 ++++++++++++++++++++++ package.json | 1 + 7 files changed, 659 insertions(+) create mode 100644 app/api/badges/[metric]/[period]/route.ts create mode 100644 components/badge-embed-card.tsx create mode 100644 lib/badges.test.ts create mode 100644 lib/badges.ts diff --git a/app/api/badges/[metric]/[period]/route.ts b/app/api/badges/[metric]/[period]/route.ts new file mode 100644 index 0000000..927176e --- /dev/null +++ b/app/api/badges/[metric]/[period]/route.ts @@ -0,0 +1,126 @@ +import { + BADGE_METRICS, + BADGE_PERIODS, + getModelBadgeStatus, + isBadgeMetric, + isBadgePeriod, +} from "@/lib/badges"; + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + +function renderBadgeSvg({ + title, + model, + detail, + footer, + accent, +}: { + title: string; + model: string; + detail: string; + footer: string; + accent: string; +}): string { + return ` + + + + + PinchBench + + ${escapeXml(detail)} + ${escapeXml(title)} + ${escapeXml(model)} + ${escapeXml(footer)} +`; +} + +function badgeResponse(svg: string, status = 200): Response { + return new Response(svg, { + status, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "public, max-age=300, stale-while-revalidate=3600", + }, + }); +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ metric: string; period: string }> }, +) { + const { metric, period } = await params; + const { searchParams } = new URL(request.url); + const model = searchParams.get("model")?.trim(); + + if (!isBadgeMetric(metric) || !isBadgePeriod(period)) { + return badgeResponse( + renderBadgeSvg({ + title: "Invalid PinchBench badge", + model: "Unsupported metric or period", + detail: "Invalid", + footer: "Supported periods: 1d, 7d, 30d • metrics: success, speed, cost, value", + accent: "#ef4444", + }), + 400, + ); + } + + if (!model) { + return badgeResponse( + renderBadgeSvg({ + title: `${BADGE_PERIODS[period].label} ${BADGE_METRICS[metric].label} Badge`, + model: "Missing model query parameter", + detail: "Missing model", + footer: 'Use ?model= to request a badge', + accent: "#ef4444", + }), + 400, + ); + } + + try { + const status = await getModelBadgeStatus(model, metric, period, { + officialOnly: searchParams.get("official") !== "false", + version: searchParams.get("version") || undefined, + }); + + const title = status.awarded + ? `${BADGE_PERIODS[period].label} ${BADGE_METRICS[metric].label} Winner` + : `${BADGE_PERIODS[period].label} ${BADGE_METRICS[metric].label} Badge`; + const footer = status.rank + ? status.awarded + ? `#1 in the last ${period} • ${status.officialOnly ? "official only" : "official + unofficial"}` + : `Current rank: #${status.rank} • winner: ${status.winnerModel ?? "N/A"}` + : `No eligible runs found in the last ${period}`; + + return badgeResponse( + renderBadgeSvg({ + title, + model, + detail: status.awarded ? status.displayValue : status.rank ? `#${status.rank}` : "No badge", + footer, + accent: BADGE_METRICS[metric].accent, + }), + status.awarded ? 200 : 404, + ); + } catch { + return badgeResponse( + renderBadgeSvg({ + title: `${BADGE_PERIODS[period].label} ${BADGE_METRICS[metric].label} Badge`, + model, + detail: "Unavailable", + footer: "PinchBench could not compute this badge right now", + accent: "#f59e0b", + }), + 503, + ); + } +} \ No newline at end of file diff --git a/app/submission/[id]/page.tsx b/app/submission/[id]/page.tsx index fcb4d74..2233766 100644 --- a/app/submission/[id]/page.tsx +++ b/app/submission/[id]/page.tsx @@ -10,6 +10,8 @@ import { ScoreGauge } from '@/components/score-gauge' import { TaskBreakdown } from '@/components/task-breakdown' import { HardwareInfo } from '@/components/hardware-info' import { TryKiloClawButton } from '@/components/try-kiloclaw-button' +import { BadgeEmbedCard } from '@/components/badge-embed-card' +import { getModelBadgeStatuses } from '@/lib/badges' import { PROVIDER_COLORS } from '@/lib/types' import { formatDistanceToNow } from 'date-fns' import { fetchSubmission } from '@/lib/api' @@ -87,6 +89,12 @@ export default async function SubmissionPage({ params, searchParams }: Submissio {} as Record ) + const badgeStatuses = await getModelBadgeStatuses(submission.model, { + officialOnly, + version: submission.benchmark_version !== 'unknown' ? submission.benchmark_version : undefined, + }) + const earnedBadges = badgeStatuses.filter((badge) => badge.awarded) + return (
{/* Header */} @@ -181,6 +189,22 @@ export default async function SubmissionPage({ params, searchParams }: Submissio officialOnly={officialOnly} />
+ {earnedBadges.length > 0 && ( +
+ {earnedBadges.map((badge) => ( + + + 🏅 {badge.shortLabel} + + + ))} +
+ )}
@@ -215,6 +239,10 @@ export default async function SubmissionPage({ params, searchParams }: Submissio
+
+ +
+ diff --git a/bun.lock b/bun.lock index 02c11f9..a0cdf7b 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.13", + "@types/bun": "^1.3.11", "@types/node": "^22", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", @@ -359,6 +360,8 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "8.5.6", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], @@ -403,6 +406,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "2.9.11", "caniuse-lite": "1.0.30001761", "electron-to-chromium": "1.5.267", "node-releases": "2.0.27", "update-browserslist-db": "1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="], diff --git a/components/badge-embed-card.tsx b/components/badge-embed-card.tsx new file mode 100644 index 0000000..d8a5f04 --- /dev/null +++ b/components/badge-embed-card.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Check, Copy, ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import type { ModelBadgeStatus } from '@/lib/badges' + +interface BadgeEmbedCardProps { + model: string + badges: ModelBadgeStatus[] +} + +export function BadgeEmbedCard({ model, badges }: BadgeEmbedCardProps) { + const [copiedKey, setCopiedKey] = useState(null) + const earnedBadges = useMemo(() => badges.filter((badge) => badge.awarded), [badges]) + + const origin = typeof window !== 'undefined' ? window.location.origin : '' + + const copyText = async (key: string, value: string) => { + try { + await navigator.clipboard.writeText(value) + setCopiedKey(key) + setTimeout(() => setCopiedKey(null), 1800) + } catch { + // noop + } + } + + if (earnedBadges.length === 0) { + return ( + +

Rolling-window badges

+

+ {model} does not currently hold a daily, + weekly, or monthly winner badge for success, speed, cost, or value. +

+
+ ) + } + + return ( + +
+

Embed winner badges

+

+ Copy a public SVG badge URL, Markdown snippet, or HTML image tag for the badges this model + currently holds. +

+
+ +
+ {earnedBadges.map((badge) => { + const absoluteUrl = `${origin}${badge.url}` + const markdown = `![${badge.title}](${absoluteUrl})` + const html = `${badge.title}` + + return ( +
+
+
+

{badge.title}

+

+ {badge.displayValue} • {badge.officialOnly ? 'official only' : 'official + unofficial'} +

+
+ + + +
+ + {badge.title} + +
+ {[ + ['url', absoluteUrl, 'Copy URL'], + ['md', markdown, 'Markdown'], + ['html', html, 'HTML'], + ].map(([kind, value, label]) => { + const key = `${badge.metric}-${badge.period}-${kind}` + const copied = copiedKey === key + return ( + + ) + })} +
+
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/lib/badges.test.ts b/lib/badges.test.ts new file mode 100644 index 0000000..fdb5af2 --- /dev/null +++ b/lib/badges.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test' +import { + calculateValueScore, + compareBadgeCandidates, + computeModelBadgeStatuses, + rankModelsForMetric, +} from './badges' +import type { ApiSubmissionListItem } from './types' + +const baseTime = new Date('2026-03-28T12:00:00.000Z').getTime() + +function submission(overrides: Partial): ApiSubmissionListItem { + return { + id: overrides.id ?? crypto.randomUUID(), + model: overrides.model ?? 'openai/gpt-test', + provider: overrides.provider ?? 'openai', + score_percentage: overrides.score_percentage ?? 0.8, + total_score: overrides.total_score ?? 32, + max_score: overrides.max_score ?? 40, + total_execution_time_seconds: overrides.total_execution_time_seconds ?? 100, + total_cost_usd: overrides.total_cost_usd ?? 10, + timestamp: overrides.timestamp ?? new Date(baseTime).toISOString(), + created_at: overrides.created_at ?? new Date(baseTime).toISOString(), + client_version: overrides.client_version ?? null, + openclaw_version: overrides.openclaw_version ?? null, + benchmark_version: overrides.benchmark_version ?? 'bench-123', + claimed: overrides.claimed ?? 1, + official: overrides.official ?? true, + } +} + +describe('badge helpers', () => { + test('calculateValueScore guards against zero cost', () => { + expect(calculateValueScore(0.9, 0)).toBeNull() + expect(calculateValueScore(0.9, 5)).toBe(18) + }) + + test('rankModelsForMetric picks best run per model', () => { + const ranked = rankModelsForMetric( + [ + submission({ model: 'a', score_percentage: 0.85 }), + submission({ model: 'a', score_percentage: 0.9 }), + submission({ model: 'b', score_percentage: 0.88 }), + ], + 'success', + ) + + expect(ranked).toHaveLength(2) + expect(ranked[0]?.model).toBe('a') + expect(ranked[0]?.scorePercentage).toBe(0.9) + }) + + test('compareBadgeCandidates prefers lower time for speed', () => { + const faster = rankModelsForMetric( + [ + submission({ model: 'a', total_execution_time_seconds: 55 }), + submission({ model: 'b', total_execution_time_seconds: 72 }), + ], + 'speed', + ) + expect(compareBadgeCandidates('speed', faster[0], faster[1])).toBeLessThan(0) + }) + + test('computeModelBadgeStatuses marks winners in each window', () => { + const statuses = computeModelBadgeStatuses( + [ + submission({ + model: 'winner', + score_percentage: 0.95, + timestamp: new Date(baseTime - 2 * 60 * 60 * 1000).toISOString(), + }), + submission({ + model: 'challenger', + score_percentage: 0.9, + timestamp: new Date(baseTime - 2 * 60 * 60 * 1000).toISOString(), + }), + ], + 'winner', + { now: baseTime, version: 'bench-123' }, + ) + + const dailySuccess = statuses.find((status) => status.period === '1d' && status.metric === 'success') + expect(dailySuccess?.awarded).toBe(true) + expect(dailySuccess?.rank).toBe(1) + expect(dailySuccess?.url).toContain('/api/badges/success/1d?model=winner') + }) +}) \ No newline at end of file diff --git a/lib/badges.ts b/lib/badges.ts new file mode 100644 index 0000000..10a0517 --- /dev/null +++ b/lib/badges.ts @@ -0,0 +1,301 @@ +import type { ApiSubmissionListItem } from "@/lib/types"; +import { fetchSubmissions } from "@/lib/api"; +import { normalizeProvider } from "@/lib/transforms"; + +const EPSILON = 1e-6; +const PAGE_SIZE = 200; +const MAX_PAGES = 25; + +export const BADGE_PERIODS = { + "1d": { label: "Daily", shortLabel: "1D", durationMs: 24 * 60 * 60 * 1000 }, + "7d": { label: "Weekly", shortLabel: "7D", durationMs: 7 * 24 * 60 * 60 * 1000 }, + "30d": { label: "Monthly", shortLabel: "30D", durationMs: 30 * 24 * 60 * 60 * 1000 }, +} as const; + +export const BADGE_METRICS = { + success: { label: "Success", accent: "#22c55e" }, + speed: { label: "Speed", accent: "#38bdf8" }, + cost: { label: "Cost", accent: "#f59e0b" }, + value: { label: "Value", accent: "#a855f7" }, +} as const; + +export type BadgeMetric = keyof typeof BADGE_METRICS; +export type BadgePeriod = keyof typeof BADGE_PERIODS; + +export interface BadgeLookupOptions { + officialOnly?: boolean; + version?: string; + now?: number; +} + +export interface BadgeCandidate { + submissionId: string; + model: string; + provider: string; + timestamp: string; + timestampMs: number; + scorePercentage: number; + executionTimeSeconds: number; + costUsd: number; + valueScore: number | null; +} + +export interface ModelBadgeStatus { + metric: BadgeMetric; + period: BadgePeriod; + title: string; + shortLabel: string; + awarded: boolean; + rank: number | null; + displayValue: string; + winnerModel: string | null; + latestTimestamp: string | null; + officialOnly: boolean; + version: string | null; + url: string; +} + +function getTimestampMs(timestamp: string): number { + const ms = new Date(timestamp).getTime(); + return Number.isFinite(ms) ? ms : Number.NaN; +} + +export function calculateValueScore( + scorePercentage: number, + costUsd: number, +): number | null { + if (!Number.isFinite(scorePercentage) || !Number.isFinite(costUsd) || costUsd <= EPSILON) { + return null; + } + return (scorePercentage * 100) / costUsd; +} + +function toCandidate(submission: ApiSubmissionListItem): BadgeCandidate { + return { + submissionId: submission.id, + model: submission.model, + provider: normalizeProvider(submission.provider, submission.model), + timestamp: submission.timestamp, + timestampMs: getTimestampMs(submission.timestamp), + scorePercentage: submission.score_percentage, + executionTimeSeconds: submission.total_execution_time_seconds, + costUsd: submission.total_cost_usd, + valueScore: calculateValueScore( + submission.score_percentage, + submission.total_cost_usd, + ), + }; +} + +function compareNumbers(a: number, b: number): number { + if (Math.abs(a - b) <= EPSILON) return 0; + return a - b; +} + +export function compareBadgeCandidates( + metric: BadgeMetric, + a: BadgeCandidate, + b: BadgeCandidate, +): number { + if (metric === "success") { + return ( + compareNumbers(b.scorePercentage, a.scorePercentage) || + compareNumbers(b.timestampMs, a.timestampMs) + ); + } + + if (metric === "speed") { + return ( + compareNumbers(a.executionTimeSeconds, b.executionTimeSeconds) || + compareNumbers(b.scorePercentage, a.scorePercentage) || + compareNumbers(b.timestampMs, a.timestampMs) + ); + } + + if (metric === "cost") { + return ( + compareNumbers(a.costUsd, b.costUsd) || + compareNumbers(b.scorePercentage, a.scorePercentage) || + compareNumbers(b.timestampMs, a.timestampMs) + ); + } + + const aValue = a.valueScore ?? Number.NEGATIVE_INFINITY; + const bValue = b.valueScore ?? Number.NEGATIVE_INFINITY; + return ( + compareNumbers(bValue, aValue) || + compareNumbers(b.scorePercentage, a.scorePercentage) || + compareNumbers(b.timestampMs, a.timestampMs) + ); +} + +function hasMetricValue(metric: BadgeMetric, candidate: BadgeCandidate): boolean { + if (!Number.isFinite(candidate.timestampMs)) return false; + if (metric === "value") return candidate.valueScore != null; + return true; +} + +export function rankModelsForMetric( + submissions: ApiSubmissionListItem[], + metric: BadgeMetric, +): BadgeCandidate[] { + const bestByModel = new Map(); + + for (const submission of submissions) { + const candidate = toCandidate(submission); + if (!hasMetricValue(metric, candidate)) continue; + const current = bestByModel.get(candidate.model); + if (!current || compareBadgeCandidates(metric, candidate, current) < 0) { + bestByModel.set(candidate.model, candidate); + } + } + + return [...bestByModel.values()].sort((a, b) => compareBadgeCandidates(metric, a, b)); +} + +function formatBadgeValue(metric: BadgeMetric, candidate: BadgeCandidate | null): string { + if (!candidate) return "N/A"; + if (metric === "success") return `${(candidate.scorePercentage * 100).toFixed(1)}%`; + if (metric === "speed") return `${candidate.executionTimeSeconds.toFixed(1)}s`; + if (metric === "cost") return `$${candidate.costUsd.toFixed(2)}`; + return candidate.valueScore == null ? "N/A" : candidate.valueScore.toFixed(1); +} + +export function isBadgeMetric(value: string): value is BadgeMetric { + return value in BADGE_METRICS; +} + +export function isBadgePeriod(value: string): value is BadgePeriod { + return value in BADGE_PERIODS; +} + +export function buildBadgeUrl( + metric: BadgeMetric, + period: BadgePeriod, + options: { model: string; officialOnly?: boolean; version?: string | null }, +): string { + const params = new URLSearchParams({ model: options.model }); + if (options.officialOnly === false) params.set("official", "false"); + if (options.version) params.set("version", options.version); + return `/api/badges/${metric}/${period}?${params.toString()}`; +} + +export async function fetchRecentBadgeSubmissions( + options: BadgeLookupOptions & { maxPeriod?: BadgePeriod }, +): Promise { + const maxPeriod = options.maxPeriod ?? "30d"; + const now = options.now ?? Date.now(); + const cutoffMs = now - BADGE_PERIODS[maxPeriod].durationMs; + const results: ApiSubmissionListItem[] = []; + + for (let page = 0; page < MAX_PAGES; page += 1) { + const response = await fetchSubmissions( + options.version, + PAGE_SIZE, + page * PAGE_SIZE, + { officialOnly: options.officialOnly ?? true }, + ); + + if (response.submissions.length === 0) break; + + let allOlderThanWindow = true; + for (const submission of response.submissions) { + const timestampMs = getTimestampMs(submission.timestamp); + if (!Number.isFinite(timestampMs)) continue; + if (timestampMs >= cutoffMs) { + results.push(submission); + allOlderThanWindow = false; + } + } + + if (!response.has_more || allOlderThanWindow) break; + } + + return results; +} + +export function computeModelBadgeStatuses( + submissions: ApiSubmissionListItem[], + model: string, + options: BadgeLookupOptions = {}, +): ModelBadgeStatus[] { + const now = options.now ?? Date.now(); + const officialOnly = options.officialOnly ?? true; + const periods = Object.keys(BADGE_PERIODS) as BadgePeriod[]; + const metrics = Object.keys(BADGE_METRICS) as BadgeMetric[]; + + return periods.flatMap((period) => { + const windowStart = now - BADGE_PERIODS[period].durationMs; + const periodSubmissions = submissions.filter((submission) => { + const timestampMs = getTimestampMs(submission.timestamp); + return Number.isFinite(timestampMs) && timestampMs >= windowStart; + }); + + return metrics.map((metric) => { + const rankings = rankModelsForMetric(periodSubmissions, metric); + const winner = rankings[0] ?? null; + const rank = rankings.findIndex((candidate) => candidate.model === model); + const current = rank >= 0 ? rankings[rank] : null; + return { + metric, + period, + title: `${BADGE_PERIODS[period].label} ${BADGE_METRICS[metric].label} Winner`, + shortLabel: `${BADGE_PERIODS[period].shortLabel} ${BADGE_METRICS[metric].label}`, + awarded: rank === 0 && winner != null, + rank: rank >= 0 ? rank + 1 : null, + displayValue: formatBadgeValue(metric, current), + winnerModel: winner?.model ?? null, + latestTimestamp: current?.timestamp ?? null, + officialOnly, + version: options.version ?? null, + url: buildBadgeUrl(metric, period, { + model, + officialOnly, + version: options.version ?? null, + }), + } satisfies ModelBadgeStatus; + }); + }); +} + +export async function getModelBadgeStatuses( + model: string, + options: BadgeLookupOptions = {}, +): Promise { + const submissions = await fetchRecentBadgeSubmissions({ + ...options, + maxPeriod: "30d", + }); + return computeModelBadgeStatuses(submissions, model, options); +} + +export async function getModelBadgeStatus( + model: string, + metric: BadgeMetric, + period: BadgePeriod, + options: BadgeLookupOptions = {}, +): Promise { + const submissions = await fetchRecentBadgeSubmissions({ + ...options, + maxPeriod: period, + }); + const statuses = computeModelBadgeStatuses(submissions, model, options); + return statuses.find((status) => status.metric === metric && status.period === period) ?? { + metric, + period, + title: `${BADGE_PERIODS[period].label} ${BADGE_METRICS[metric].label} Winner`, + shortLabel: `${BADGE_PERIODS[period].shortLabel} ${BADGE_METRICS[metric].label}`, + awarded: false, + rank: null, + displayValue: "N/A", + winnerModel: null, + latestTimestamp: null, + officialOnly: options.officialOnly ?? true, + version: options.version ?? null, + url: buildBadgeUrl(metric, period, { + model, + officialOnly: options.officialOnly ?? true, + version: options.version ?? null, + }), + }; +} \ No newline at end of file diff --git a/package.json b/package.json index 89215b2..3a84686 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.13", + "@types/bun": "^1.3.11", "@types/node": "^22", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", From 77b662ece50b78ec9952ce0b997d4513c797092b Mon Sep 17 00:00:00 2001 From: oleksiiovdiienko Date: Tue, 31 Mar 2026 21:16:02 +0300 Subject: [PATCH 2/2] feat(badges): add period normalization and aliases for badge metrics --- app/api/badges/[metric]/[period]/route.ts | 13 ++++-- lib/badges.test.ts | 14 ++++++ lib/badges.ts | 56 ++++++++++++++++++----- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/app/api/badges/[metric]/[period]/route.ts b/app/api/badges/[metric]/[period]/route.ts index 927176e..2e3baf2 100644 --- a/app/api/badges/[metric]/[period]/route.ts +++ b/app/api/badges/[metric]/[period]/route.ts @@ -4,6 +4,7 @@ import { getModelBadgeStatus, isBadgeMetric, isBadgePeriod, + normalizePeriod, } from "@/lib/badges"; function escapeXml(value: string): string { @@ -56,23 +57,25 @@ export async function GET( request: Request, { params }: { params: Promise<{ metric: string; period: string }> }, ) { - const { metric, period } = await params; + const { metric, period: rawPeriod } = await params; const { searchParams } = new URL(request.url); const model = searchParams.get("model")?.trim(); - if (!isBadgeMetric(metric) || !isBadgePeriod(period)) { + if (!isBadgeMetric(metric) || !isBadgePeriod(rawPeriod)) { return badgeResponse( renderBadgeSvg({ title: "Invalid PinchBench badge", model: "Unsupported metric or period", detail: "Invalid", - footer: "Supported periods: 1d, 7d, 30d • metrics: success, speed, cost, value", + footer: "Supported periods: 1d, 7d, 30d (or daily, weekly, monthly) • metrics: success, speed, cost, value", accent: "#ef4444", }), 400, ); } + const period = normalizePeriod(rawPeriod); + if (!model) { return badgeResponse( renderBadgeSvg({ @@ -109,7 +112,7 @@ export async function GET( footer, accent: BADGE_METRICS[metric].accent, }), - status.awarded ? 200 : 404, + 200, ); } catch { return badgeResponse( @@ -120,7 +123,7 @@ export async function GET( footer: "PinchBench could not compute this badge right now", accent: "#f59e0b", }), - 503, + 200, ); } } \ No newline at end of file diff --git a/lib/badges.test.ts b/lib/badges.test.ts index fdb5af2..ce80ea8 100644 --- a/lib/badges.test.ts +++ b/lib/badges.test.ts @@ -84,4 +84,18 @@ describe('badge helpers', () => { expect(dailySuccess?.rank).toBe(1) expect(dailySuccess?.url).toContain('/api/badges/success/1d?model=winner') }) + + test('period aliases and normalization', () => { + const { isBadgePeriod, normalizePeriod } = require('./badges') + expect(isBadgePeriod('1d')).toBe(true) + expect(isBadgePeriod('daily')).toBe(true) + expect(isBadgePeriod('weekly')).toBe(true) + expect(isBadgePeriod('monthly')).toBe(true) + expect(isBadgePeriod('yearly')).toBe(false) + + expect(normalizePeriod('daily')).toBe('1d') + expect(normalizePeriod('weekly')).toBe('7d') + expect(normalizePeriod('monthly')).toBe('30d') + expect(normalizePeriod('1d')).toBe('1d') + }) }) \ No newline at end of file diff --git a/lib/badges.ts b/lib/badges.ts index 10a0517..b08751c 100644 --- a/lib/badges.ts +++ b/lib/badges.ts @@ -3,15 +3,21 @@ import { fetchSubmissions } from "@/lib/api"; import { normalizeProvider } from "@/lib/transforms"; const EPSILON = 1e-6; -const PAGE_SIZE = 200; -const MAX_PAGES = 25; +const PAGE_SIZE = 1000; +const MAX_PAGES = 5; export const BADGE_PERIODS = { - "1d": { label: "Daily", shortLabel: "1D", durationMs: 24 * 60 * 60 * 1000 }, - "7d": { label: "Weekly", shortLabel: "7D", durationMs: 7 * 24 * 60 * 60 * 1000 }, - "30d": { label: "Monthly", shortLabel: "30D", durationMs: 30 * 24 * 60 * 60 * 1000 }, + "1d": { label: "Daily", shortLabel: "1D" }, + "7d": { label: "Weekly", shortLabel: "7D" }, + "30d": { label: "Monthly", shortLabel: "30D" }, } as const; +export const PERIOD_ALIASES: Record = { + daily: "1d", + weekly: "7d", + monthly: "30d", +}; + export const BADGE_METRICS = { success: { label: "Success", accent: "#22c55e" }, speed: { label: "Speed", accent: "#38bdf8" }, @@ -22,6 +28,26 @@ export const BADGE_METRICS = { export type BadgeMetric = keyof typeof BADGE_METRICS; export type BadgePeriod = keyof typeof BADGE_PERIODS; +export function getPeriodStartMs(period: BadgePeriod, now: number): number { + const date = new Date(now); + if (period === "1d") { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + } + if (period === "7d") { + const day = date.getUTCDay(); // 0 is Sunday + const diff = day === 0 ? 6 : day - 1; // Monday as start of week + return Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() - diff, + ); + } + if (period === "30d") { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); + } + return now; +} + export interface BadgeLookupOptions { officialOnly?: boolean; version?: string; @@ -166,7 +192,12 @@ export function isBadgeMetric(value: string): value is BadgeMetric { } export function isBadgePeriod(value: string): value is BadgePeriod { - return value in BADGE_PERIODS; + return value in BADGE_PERIODS || value in PERIOD_ALIASES; +} + +export function normalizePeriod(period: string): BadgePeriod { + if (period in BADGE_PERIODS) return period as BadgePeriod; + return PERIOD_ALIASES[period.toLowerCase()] || "30d"; } export function buildBadgeUrl( @@ -185,7 +216,7 @@ export async function fetchRecentBadgeSubmissions( ): Promise { const maxPeriod = options.maxPeriod ?? "30d"; const now = options.now ?? Date.now(); - const cutoffMs = now - BADGE_PERIODS[maxPeriod].durationMs; + const cutoffMs = getPeriodStartMs(maxPeriod, now); const results: ApiSubmissionListItem[] = []; for (let page = 0; page < MAX_PAGES; page += 1) { @@ -217,15 +248,15 @@ export async function fetchRecentBadgeSubmissions( export function computeModelBadgeStatuses( submissions: ApiSubmissionListItem[], model: string, - options: BadgeLookupOptions = {}, + options: BadgeLookupOptions & { periods?: BadgePeriod[] } = {}, ): ModelBadgeStatus[] { const now = options.now ?? Date.now(); const officialOnly = options.officialOnly ?? true; - const periods = Object.keys(BADGE_PERIODS) as BadgePeriod[]; + const periods = options.periods ?? (Object.keys(BADGE_PERIODS) as BadgePeriod[]); const metrics = Object.keys(BADGE_METRICS) as BadgeMetric[]; return periods.flatMap((period) => { - const windowStart = now - BADGE_PERIODS[period].durationMs; + const windowStart = getPeriodStartMs(period, now); const periodSubmissions = submissions.filter((submission) => { const timestampMs = getTimestampMs(submission.timestamp); return Number.isFinite(timestampMs) && timestampMs >= windowStart; @@ -279,7 +310,10 @@ export async function getModelBadgeStatus( ...options, maxPeriod: period, }); - const statuses = computeModelBadgeStatuses(submissions, model, options); + const statuses = computeModelBadgeStatuses(submissions, model, { + ...options, + periods: [period], + }); return statuses.find((status) => status.metric === metric && status.period === period) ?? { metric, period,