diff --git a/internal/ranking/client.go b/internal/ranking/client.go index 506b01b3..fe498919 100644 --- a/internal/ranking/client.go +++ b/internal/ranking/client.go @@ -131,13 +131,19 @@ type LeaderboardEntry struct { Metrics map[LeaderboardMetric]int64 `json:"metrics,omitempty"` } +type ScoreExplanation struct { + Version int `json:"version"` + Texts map[string]string `json:"texts"` +} + type Leaderboard struct { - Period LeaderboardPeriod `json:"period"` - PeriodKey string `json:"period_key"` - Metric LeaderboardMetric `json:"metric"` - GeneratedAt time.Time `json:"generated_at"` - Stale bool `json:"stale"` - Entries []LeaderboardEntry `json:"entries"` + Period LeaderboardPeriod `json:"period"` + PeriodKey string `json:"period_key"` + Metric LeaderboardMetric `json:"metric"` + GeneratedAt time.Time `json:"generated_at"` + Stale bool `json:"stale"` + Entries []LeaderboardEntry `json:"entries"` + ScoreExplanation *ScoreExplanation `json:"score_explanation,omitempty"` } type LeaderboardPeriodMetadata struct { diff --git a/internal/ranking/httpapi/test/routes_test.go b/internal/ranking/httpapi/test/routes_test.go index 5028499a..3ec987f3 100644 --- a/internal/ranking/httpapi/test/routes_test.go +++ b/internal/ranking/httpapi/test/routes_test.go @@ -67,6 +67,10 @@ func (s *rankingProviderStub) Leaderboard(_ context.Context, period ranking.Lead return ranking.Leaderboard{ Period: period, PeriodKey: "2026-07-24", Metric: metric, GeneratedAt: time.Date(2026, 7, 24, 4, 0, 0, 0, time.UTC), Entries: []ranking.LeaderboardEntry{}, + ScoreExplanation: &ranking.ScoreExplanation{ + Version: 2, + Texts: map[string]string{"en": "Overall score V2", "zh": "综合分 V2", "zh-TW": "綜合分 V2"}, + }, }, nil } @@ -177,6 +181,9 @@ func TestRankingLeaderboardValidatesAndForwardsSelection(t *testing.T) { if response.Code != http.StatusOK || provider.boardPeriod != ranking.LeaderboardToday || provider.boardMetric != ranking.MetricOverall { t.Fatalf("unexpected leaderboard result: status=%d body=%s provider=%+v", response.Code, response.Body.String(), provider) } + if !strings.Contains(response.Body.String(), `"score_explanation":{"version":2`) || !strings.Contains(response.Body.String(), `"zh":"综合分 V2"`) { + t.Fatalf("leaderboard response dropped score explanation: %s", response.Body.String()) + } if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" || response.Header().Get("Expires") != "0" { t.Fatalf("leaderboard response allowed browser caching: %+v", response.Header()) } diff --git a/internal/ranking/test/client_test.go b/internal/ranking/test/client_test.go index a50eb5ac..d8464082 100644 --- a/internal/ranking/test/client_test.go +++ b/internal/ranking/test/client_test.go @@ -182,7 +182,7 @@ func TestCenterClientReadsLeaderboardWithKeeperMarker(t *testing.T) { t.Fatalf("missing Keeper read marker: %v", request.Header) } w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"period":"today","period_key":"2026-07-24","metric":"overall","generated_at":"2026-07-24T04:05:06Z","stale":false,"entries":[]}`) + _, _ = io.WriteString(w, `{"period":"today","period_key":"2026-07-24","metric":"overall","generated_at":"2026-07-24T04:05:06Z","stale":false,"entries":[],"score_explanation":{"version":2,"texts":{"en":"Overall score V2","zh":"综合分 V2","zh-TW":"綜合分 V2"}}}`) })) defer server.Close() @@ -197,6 +197,9 @@ func TestCenterClientReadsLeaderboardWithKeeperMarker(t *testing.T) { if board.Period != ranking.LeaderboardToday || board.Metric != ranking.MetricOverall || board.Entries == nil { t.Fatalf("unexpected leaderboard: %+v", board) } + if board.ScoreExplanation == nil || board.ScoreExplanation.Version != 2 || board.ScoreExplanation.Texts["zh"] != "综合分 V2" { + t.Fatalf("score explanation was not preserved: %+v", board.ScoreExplanation) + } } func TestCenterClientValidatesFixedMetadataContract(t *testing.T) { diff --git a/web/src/features/ranking/RankingPage.module.scss b/web/src/features/ranking/RankingPage.module.scss index 4a831825..37da817d 100644 --- a/web/src/features/ranking/RankingPage.module.scss +++ b/web/src/features/ranking/RankingPage.module.scss @@ -41,6 +41,10 @@ min-width: 0; } +.leaderboardTitle :global(.keeper-card-title-track) { + position: relative; +} + .leaderboardTitle :global(.keeper-card-title) { overflow: hidden; text-overflow: ellipsis; @@ -107,6 +111,10 @@ } } +.scoreExplanationHint { + flex: 0 0 18px; +} + .profilePrivacyTooltip { position: absolute; z-index: 30; diff --git a/web/src/features/ranking/RankingPage.tsx b/web/src/features/ranking/RankingPage.tsx index 65622217..c6f70488 100644 --- a/web/src/features/ranking/RankingPage.tsx +++ b/web/src/features/ranking/RankingPage.tsx @@ -97,6 +97,23 @@ const formatError = (error: unknown, t: Translate): string => { const profileErrorKey = (error: RankingProfileError) => `ranking.profile_error_${error}`; +const resolveScoreExplanation = ( + board: RankingLeaderboardResponse | null, + metric: RankingMetric, + language: string, +): string => { + if (metric !== 'overall' || board?.metric !== 'overall' || board.score_explanation?.version !== 2) return ''; + const texts = board.score_explanation.texts; + if (!texts) return ''; + const candidates = [ + texts[language as keyof typeof texts], + texts.en, + texts.zh, + texts['zh-TW'], + ]; + return candidates.find((text) => text?.trim())?.trim() ?? ''; +}; + export function RankingPage(props: RankingPageProps) { const { t, i18n } = useTranslation(); const [displayName, setDisplayName] = useState(''); @@ -571,15 +588,41 @@ function LeaderboardCard({ t, language, }: LeaderboardCardProps) { + const [scoreExplanationOpen, setScoreExplanationOpen] = useState(false); + const scoreExplanationID = useId(); const rows = useMemo(() => board?.entries.slice(0, 100) ?? [], [board]); const podium = rows.slice(0, 3); const tableRows = rows; + const scoreExplanation = resolveScoreExplanation(board, metric, language); return (

{t(`ranking.metric_${metric}`)}

+ {scoreExplanation ? ( + + ) : null}
{board && (
diff --git a/web/src/features/ranking/test/RankingPage.styles.test.ts b/web/src/features/ranking/test/RankingPage.styles.test.ts index b9da7b2a..2a758266 100644 --- a/web/src/features/ranking/test/RankingPage.styles.test.ts +++ b/web/src/features/ranking/test/RankingPage.styles.test.ts @@ -114,6 +114,16 @@ describe('Ranking table context styles', () => { expect(styles).toContain('.profilePrivacyHintOpen .profilePrivacyTooltip'); }); + it('reuses the participation question style beside the overall title', () => { + const titleTrack = rule('.leaderboardTitle :global(.keeper-card-title-track)'); + const scoreHint = rule('.scoreExplanationHint'); + expect(source).toContain('styles.profilePrivacyHint'); + expect(source).toContain('styles.profilePrivacyTooltip'); + expect(source).toContain('data-ranking-score-explanation'); + expect(titleTrack).toContain('position: relative;'); + expect(scoreHint).not.toContain('position: relative;'); + }); + it('aligns the title with the top filters and keeps the profile entry directly below them', () => { const header = rule('.leaderboardHeader'); expect(header).toContain('display: grid;'); diff --git a/web/src/features/ranking/test/RankingPage.test.tsx b/web/src/features/ranking/test/RankingPage.test.tsx index 956891a8..969ba572 100644 --- a/web/src/features/ranking/test/RankingPage.test.tsx +++ b/web/src/features/ranking/test/RankingPage.test.tsx @@ -22,6 +22,14 @@ const leaderboard: RankingLeaderboardResponse = { metric: 'overall', generated_at: '2026-07-24T04:05:00Z', stale: false, + score_explanation: { + version: 2, + texts: { + en: 'Overall score V2 from the ranking center.', + zh: '中心返回的综合分 V2。', + 'zh-TW': '中心回傳的綜合分 V2。', + }, + }, entries: [ { rank: 4, @@ -147,6 +155,42 @@ describe('RankingPage', () => { expect(title?.textContent).not.toContain('ranking.period_today'); }); + it('shows the center explanation beside only a V2 overall title', async () => { + await renderPage(); + + const hint = container.querySelector('[data-ranking-score-explanation]'); + const tooltip = hint?.querySelector('[data-ranking-score-explanation-tooltip]'); + expect(hint?.textContent).toContain('?'); + expect(hint?.getAttribute('title')).toBeNull(); + expect(hint?.getAttribute('aria-label')).toBe('ranking.score_explanation_label'); + expect(hint?.getAttribute('aria-label')).not.toBe(tooltip?.textContent); + expect(hint?.getAttribute('aria-describedby')).toBe(tooltip?.id); + expect(hint?.getAttribute('aria-expanded')).toBe('false'); + expect(tooltip?.getAttribute('role')).toBe('tooltip'); + expect(tooltip?.textContent).toBe('Overall score V2 from the ranking center.'); + await act(async () => hint?.click()); + expect(hint?.getAttribute('aria-expanded')).toBe('true'); + + await renderPage({ + metric: 'total_tokens', + leaderboard: { ...leaderboard, metric: 'total_tokens' }, + }); + expect(container.querySelector('[data-ranking-score-explanation]')).toBeNull(); + + await renderPage({ leaderboard: { ...leaderboard, score_explanation: undefined } }); + expect(container.querySelector('[data-ranking-score-explanation]')).toBeNull(); + + await renderPage({ + leaderboard: { ...leaderboard, score_explanation: { version: 2, texts: null } }, + }); + expect(container.querySelector('[data-ranking-score-explanation]')).toBeNull(); + + await renderPage({ + leaderboard: { ...leaderboard, score_explanation: { version: 1, texts: { en: 'Legacy score' } } }, + }); + expect(container.querySelector('[data-ranking-score-explanation]')).toBeNull(); + }); + it('keeps the leaderboard as the only page card and moves participation into one large modal', async () => { await renderPage(); diff --git a/web/src/features/ranking/types.ts b/web/src/features/ranking/types.ts index b6e471b0..b5643373 100644 --- a/web/src/features/ranking/types.ts +++ b/web/src/features/ranking/types.ts @@ -43,12 +43,18 @@ export interface RankingLeaderboardEntry { metrics?: Partial>; } +export interface RankingScoreExplanation { + version: number; + texts?: Partial> | null; +} + export interface RankingLeaderboardResponse { period: RankingPeriod; period_key: string; metric: RankingMetric; generated_at: string; stale: boolean; + score_explanation?: RankingScoreExplanation; entries: RankingLeaderboardEntry[]; } diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 4d8c0404..e50b05ea 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -646,6 +646,7 @@ const resources = { period_previous_month: 'Last Month', metric_label: 'Ranking Metric', metric_overall: 'Overall Ranking', + score_explanation_label: 'Overall Ranking Algorithm Explanation', metric_total_tokens: 'Total Token', metric_request_count: 'Requests', metric_cache_read_rate: 'Cache Read Rate', @@ -1355,6 +1356,7 @@ const resources = { period_previous_month: '上月榜', metric_label: '榜单类型', metric_overall: '综合排名', + score_explanation_label: '综合排名算法说明', metric_total_tokens: '总 Token', metric_request_count: '请求数', metric_cache_read_rate: '缓存读取率', @@ -2064,6 +2066,7 @@ const resources = { period_previous_month: '上月榜', metric_label: '榜單類型', metric_overall: '綜合排名', + score_explanation_label: '綜合排名演算法說明', metric_total_tokens: '總 Token', metric_request_count: '請求數', metric_cache_read_rate: '快取讀取率', diff --git a/web/src/i18n/test/rankingTranslations.test.ts b/web/src/i18n/test/rankingTranslations.test.ts index 78292dc8..57c6e004 100644 --- a/web/src/i18n/test/rankingTranslations.test.ts +++ b/web/src/i18n/test/rankingTranslations.test.ts @@ -13,6 +13,7 @@ const rankingKeys = [ 'ranking.period_current_month', 'ranking.period_previous_month', 'ranking.metric_overall', + 'ranking.score_explanation_label', 'ranking.metric_total_tokens', 'ranking.metric_request_count', 'ranking.metric_cache_read_rate',