누적 랭킹 보기 탭 추가

develop
jwkim 2026-06-18 17:01:41 +09:00
parent 39c2003080
commit a8e20d5ac8
8 changed files with 279 additions and 59 deletions

View File

@ -9,11 +9,17 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models import Match, UserPoints
from ..scoring import is_excluded
from ..schemas import LeaderboardOut, StandingOut
from ..scoring import is_excluded, score_prediction
from ..schemas import (
AILeaderboardOut,
AIStandingOut,
LeaderboardOut,
StandingOut,
)
router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"])
@ -62,3 +68,47 @@ async def leaderboard(
for i, r in enumerate(ranked[:limit])
]
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
@router.get("/ai", response_model=AILeaderboardOut)
async def ai_leaderboard(db: AsyncSession = Depends(get_db)) -> AILeaderboardOut:
"""AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산.
별도 누적 테이블 없이 조회 종료 경기 전수 재계산 결과/배점 변동에
항상 일관. (모델 3 × 경기 수라 비용 무시 가능.)
"""
matches = (
await db.execute(
select(Match)
.where(Match.result_outcome.is_not(None))
.options(selectinload(Match.predictions))
)
).scalars().all()
# model → [points, exact_count, matches_played]
agg: dict[str, list[int]] = {}
for m in matches:
for p in m.predictions:
pts = score_prediction(p.score_a, p.score_b, m.result_score_a, m.result_score_b)
row = agg.setdefault(p.model, [0, 0, 0])
row[0] += pts
row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0
row[2] += 1
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
ranked = sorted(
agg.items(),
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2], order.get(kv[0], 9)),
)
standings = [
AIStandingOut(
rank=i + 1,
model=model, # type: ignore[arg-type]
totalPoints=pts,
exactCount=exact,
matchesPlayed=played,
)
for i, (model, (pts, exact, played)) in enumerate(ranked)
]
return AILeaderboardOut(standings=standings, scoredMatches=len(matches))

View File

@ -124,6 +124,20 @@ class LeaderboardOut(BaseModel):
scoredMatches: int
# ── AI 모델 랭킹 (종료 경기 누적, 매 조회 시 재계산) ──────────
class AIStandingOut(BaseModel):
rank: int
model: ModelName
totalPoints: int
exactCount: int
matchesPlayed: int
class AILeaderboardOut(BaseModel):
standings: list[AIStandingOut]
scoredMatches: int
# ── 관리자 ──────────────────────────────────────────────────
class AdminAIPredictionIn(BaseModel):
matchId: str

View File

@ -19,6 +19,7 @@ import {
submitPrediction,
} from "@/lib/api";
import Countdown from "./Countdown";
import RankingBoard from "./RankingBoard";
import TeamFlag from "./TeamFlag";
type Step = "form" | "done";
@ -457,6 +458,9 @@ export default function Arena({
</section>
)}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */}
<RankingBoard lang={lang} />
{/* ===== 골드 상금 (003) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3">

View File

@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import { type Lang, dict } from "@/lib/i18n";
import { fetchRanking } from "@/lib/api";
import type { RankingEntry, RankingTab } from "@/lib/types";
// 누적 랭킹 — 참가자 / AI 모델 2탭, 폴딩(기본 접힘).
// 경기 페이지(골드 카드 위)에 삽입. defaultOpen=true 면 전체 페이지용 펼친 상태.
// 데이터는 서버 누적(경기 종료·채점마다 갱신) — 펼치거나 탭 바꿀 때마다 최신 조회.
export default function RankingBoard({
lang = "ko",
defaultOpen = false,
}: {
lang?: Lang;
defaultOpen?: boolean;
}) {
const t = dict(lang);
const [open, setOpen] = useState(defaultOpen);
const [tab, setTab] = useState<RankingTab>("voters");
const [rows, setRows] = useState<RankingEntry[]>([]);
const [loaded, setLoaded] = useState(false);
// 펼쳐졌을 때 + 탭 변경 시에만 조회 (접힌 동안은 네트워크 안 씀)
useEffect(() => {
if (!open) return;
let alive = true;
setLoaded(false);
fetchRanking(tab)
.then((r) => {
if (!alive) return;
setRows(r);
setLoaded(true);
})
.catch(() => {
if (!alive) return;
setRows([]);
setLoaded(true);
});
return () => {
alive = false;
};
}, [open, tab]);
return (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<h3 className="text-[19px] font-extrabold">{t.lbTitle}</h3>
{open && (
<>
{/* 탭 */}
<div className="mt-3 grid grid-cols-2 gap-2">
{(
[
["voters", t.lbTabVoters],
["ai", t.lbTabAI],
] as [RankingTab, string][]
).map(([val, label]) => (
<button
key={val}
onClick={() => setTab(val)}
className={`rounded-xl py-2.5 text-[15px] font-extrabold transition ${
tab === val
? "bg-[var(--share)] text-white"
: "border border-[var(--line-d)] text-[var(--ink-muted)]"
}`}
>
{label}
</button>
))}
</div>
{loaded && rows.length === 0 ? (
<p className="mt-4 py-3 text-center text-[13px] text-[var(--ink-muted)]">
{t.lbEmpty}
</p>
) : (
<>
{/* 헤더 라벨 */}
<div className="mt-3 flex items-center justify-between px-3 text-[12px] font-bold text-[var(--ink-muted)]">
<span>{tab === "ai" ? t.lbTabAI : t.lbTabVoters}</span>
<span>{t.lbPoints}</span>
</div>
{/* TOP 10 목록 */}
<ol className="mt-1.5 flex flex-col gap-1.5">
{rows.map((r, i) => {
const top3 = i < 3;
return (
<li
key={`${r.label}-${i}`}
className={`flex items-center gap-3 rounded-xl border px-3 py-2.5 ${
top3
? "border-[var(--share)]/50 bg-[rgba(166,94,255,0.08)]"
: "border-[var(--line-d)]"
}`}
>
<span
className={`w-6 shrink-0 text-center font-mono text-[16px] font-extrabold tabular-nums ${
top3 ? "text-[var(--share)]" : "text-[var(--ink-muted)]"
}`}
>
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate text-[16px] font-bold text-white">
{r.label}
</span>
<span className="shrink-0 font-mono text-[16px] font-extrabold tabular-nums text-white">
{r.points.toLocaleString()}
<span className="ml-0.5 text-[12px] text-[var(--ink-muted)]">
{t.lbUnit}
</span>
</span>
</li>
);
})}
</ol>
</>
)}
</>
)}
{/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */}
{!defaultOpen && (
<button
onClick={() => setOpen((v) => !v)}
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-xl border border-[var(--share)] bg-[rgba(166,94,255,0.12)] py-2.5 text-[15px] font-extrabold text-[var(--share)] transition active:scale-[0.99]"
>
{open ? t.lbClose : t.lbOpen}
<span aria-hidden>{open ? "▲" : "▼"}</span>
</button>
)}
</section>
);
}

View File

@ -1,5 +1,6 @@
// 백엔드 FastAPI 클라이언트. 동일 출처 /api (dev=vite proxy, prod=nginx proxy).
import type {
AILeaderboard,
Comment,
CommentList,
CrowdStats,
@ -7,6 +8,8 @@ import type {
Match,
ModelName,
MyPrediction,
RankingEntry,
RankingTab,
} from "./types";
import type { Lang } from "./i18n";
@ -74,6 +77,24 @@ export function getLeaderboard(): Promise<Leaderboard> {
return http<Leaderboard>(`/leaderboard`);
}
// AI 모델 누적 랭킹 (종료 경기 합산 — 서버가 매 조회 시 재계산)
export function getAILeaderboard(): Promise<AILeaderboard> {
return http<AILeaderboard>(`/leaderboard/ai`);
}
// 랭킹 위젯 공용 — 탭에 맞춰 TOP 10 을 {label, points} 로 정규화해 반환.
// 경기 종료·채점 때마다 서버 값이 갱신되므로 호출 시점 최신 누적이 내려옴.
export async function fetchRanking(tab: RankingTab): Promise<RankingEntry[]> {
if (tab === "ai") {
const { standings } = await getAILeaderboard();
return standings.map((s) => ({ label: s.model, points: s.totalPoints }));
}
const { standings } = await getLeaderboard();
return standings
.slice(0, 10)
.map((s) => ({ label: s.emailMasked, points: s.totalPoints }));
}
// 내 지난 예측 — 이메일 기준 조회(로그인 없음). 최신순. 빈 이메일이면 빈 배열.
export function fetchMyPredictions(email: string): Promise<MyPrediction[]> {
const e = email.trim();

View File

@ -106,6 +106,15 @@ type Dict = {
thisMatch: string;
myVotesShowMore: (n: number) => string;
myVotesShowLess: string;
// 누적 랭킹 (참가자/AI 탭)
lbTitle: string;
lbTabVoters: string;
lbTabAI: string;
lbPoints: string;
lbUnit: string;
lbOpen: string;
lbClose: string;
lbEmpty: string;
commentsTitle: string;
commentsCount: (n: number) => string;
commentPh: string;
@ -204,6 +213,14 @@ export const DICT: Record<Lang, Dict> = {
thisMatch: "이번 경기",
myVotesShowMore: (n) => `더보기 +${n}`,
myVotesShowLess: "접기",
lbTitle: "누적 랭킹 TOP 10",
lbTabVoters: "참가자",
lbTabAI: "AI 모델",
lbPoints: "누적 포인트",
lbUnit: "P",
lbOpen: "랭킹 보기",
lbClose: "접기",
lbEmpty: "아직 채점된 경기가 없습니다",
commentsTitle: "한마디",
commentsCount: (n) => `${n}`,
commentPh: "응원 한마디 남기기...",
@ -299,6 +316,14 @@ export const DICT: Record<Lang, Dict> = {
thisMatch: "This match",
myVotesShowMore: (n) => `Show ${n} more`,
myVotesShowLess: "Show less",
lbTitle: "Cumulative Ranking · TOP 10",
lbTabVoters: "Players",
lbTabAI: "AI models",
lbPoints: "Points",
lbUnit: "P",
lbOpen: "View ranking",
lbClose: "Collapse",
lbEmpty: "No matches scored yet",
commentsTitle: "Comments",
commentsCount: (n) => `${n} total`,
commentPh: "Leave a comment...",

View File

@ -93,6 +93,26 @@ export interface Leaderboard {
scoredMatches: number;
}
export interface AIStanding {
rank: number;
model: ModelName;
totalPoints: number;
exactCount: number;
matchesPlayed: number;
}
export interface AILeaderboard {
standings: AIStanding[];
scoredMatches: number;
}
// 랭킹 위젯 공용 표시 항목 (참가자/AI 공통) — RankingBoard 가 사용
export type RankingTab = "voters" | "ai";
export interface RankingEntry {
label: string; // 참가자: 마스킹 이메일 / AI: 모델명
points: number;
}
export interface Comment {
nickname: string; // 축구 코믹 한국어 5글자 (서버가 기기 해시로 자동 배정)
body: string;

View File

@ -1,72 +1,25 @@
import { useEffect, useState } from "react";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
import RankingBoard from "@/components/RankingBoard";
import { useLang } from "@/lib/useLang";
import { getLeaderboard } from "@/lib/api";
import type { Leaderboard as LB } from "@/lib/types";
// L3. 리더보드 — 누적 포인트 1위. 데이터 없으면 안내 stub.
// L3. 리더보드 — 누적 포인트 랭킹(참가자/AI 탭). 펼친 상태로 전체 표시.
// 빈 상태·AI 탭·TOP10 렌더는 RankingBoard 위젯이 일괄 처리(매치 페이지와 동일 UI).
export default function Leaderboard() {
const lang = useLang();
const [data, setData] = useState<LB | null>(null);
useEffect(() => {
let alive = true;
getLeaderboard()
.then((d) => alive && setData(d))
.catch(() => alive && setData({ standings: [], scoredMatches: 0 }));
return () => {
alive = false;
};
}, []);
const empty = !data || data.standings.length === 0;
return (
<main className="shell">
<Hero back lang={lang} />
<section className="mt-8">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
{empty ? (
<div className="mt-4 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
<p className="text-[15px] font-bold">
{lang === "en" ? "Rankings open soon" : "랭킹은 곧 공개됩니다"}
</p>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
{lang === "en"
? "Points accumulate each match — the overall #1 wins ₩1,000,000."
: "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}
</p>
</div>
) : (
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--line-d)]">
{data!.standings.map((s) => (
<div
key={s.rank}
className="flex items-center justify-between border-b border-[var(--line-d)] px-4 py-3 last:border-b-0"
>
<div className="flex items-center gap-3">
<span className="w-6 text-center font-impact text-[18px] text-[var(--green)]">
{s.rank}
</span>
<span className="text-[14px] font-bold">{s.emailMasked}</span>
</div>
<div className="text-right">
<span className="text-[16px] font-extrabold">{s.totalPoints}</span>
<span className="ml-1 text-[11px] text-[var(--ink-muted)]">
{lang === "en" ? "pts" : "점"}
</span>
<span className="ml-2 text-[11px] text-[var(--ink-muted)]">
{lang === "en"
? `${s.exactCount} exact · ${s.matchesPlayed} played`
: `정확 ${s.exactCount} · ${s.matchesPlayed}경기`}
</span>
</div>
</div>
))}
</div>
)}
{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
<RankingBoard lang={lang} defaultOpen />
</section>
<Footer lang={lang} />
</main>