diff --git a/backend/app/routers/leaderboard.py b/backend/app/routers/leaderboard.py index 8735bde..3490622 100644 --- a/backend/app/routers/leaderboard.py +++ b/backend/app/routers/leaderboard.py @@ -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)) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 07bee6c..874ec50 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index 282b2f0..34b5104 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -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({ )} + {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */} + + {/* ===== 골드 상금 (003) ===== */}
diff --git a/frontend/src/components/RankingBoard.tsx b/frontend/src/components/RankingBoard.tsx new file mode 100644 index 0000000..cd5d6f5 --- /dev/null +++ b/frontend/src/components/RankingBoard.tsx @@ -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("voters"); + const [rows, setRows] = useState([]); + 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 ( +
+

{t.lbTitle}

+ + {open && ( + <> + {/* 탭 */} +
+ {( + [ + ["voters", t.lbTabVoters], + ["ai", t.lbTabAI], + ] as [RankingTab, string][] + ).map(([val, label]) => ( + + ))} +
+ + {loaded && rows.length === 0 ? ( +

+ {t.lbEmpty} +

+ ) : ( + <> + {/* 헤더 라벨 */} +
+ {tab === "ai" ? t.lbTabAI : t.lbTabVoters} + {t.lbPoints} +
+ + {/* TOP 10 목록 */} +
    + {rows.map((r, i) => { + const top3 = i < 3; + return ( +
  1. + + {i + 1} + + + {r.label} + + + {r.points.toLocaleString()} + + {t.lbUnit} + + +
  2. + ); + })} +
+ + )} + + )} + + {/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */} + {!defaultOpen && ( + + )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2b1caa6..bf719b2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { return http(`/leaderboard`); } +// AI 모델 누적 랭킹 (종료 경기 합산 — 서버가 매 조회 시 재계산) +export function getAILeaderboard(): Promise { + return http(`/leaderboard/ai`); +} + +// 랭킹 위젯 공용 — 탭에 맞춰 TOP 10 을 {label, points} 로 정규화해 반환. +// 경기 종료·채점 때마다 서버 값이 갱신되므로 호출 시점 최신 누적이 내려옴. +export async function fetchRanking(tab: RankingTab): Promise { + 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 { const e = email.trim(); diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 462717c..0f1f32b 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { 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 = { 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...", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 10d76e7..03beac7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/frontend/src/pages/Leaderboard.tsx b/frontend/src/pages/Leaderboard.tsx index 235c384..aac22ef 100644 --- a/frontend/src/pages/Leaderboard.tsx +++ b/frontend/src/pages/Leaderboard.tsx @@ -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(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 (
LEADERBOARD
- - {empty ? ( -
-

- {lang === "en" ? "Rankings open soon" : "랭킹은 곧 공개됩니다"} -

-

- {lang === "en" - ? "Points accumulate each match — the overall #1 wins ₩1,000,000." - : "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."} -

-
- ) : ( -
- {data!.standings.map((s) => ( -
-
- - {s.rank} - - {s.emailMasked} -
-
- {s.totalPoints} - - {lang === "en" ? "pts" : "점"} - - - {lang === "en" - ? `${s.exactCount} exact · ${s.matchesPlayed} played` - : `정확 ${s.exactCount} · ${s.matchesPlayed}경기`} - -
-
- ))} -
- )} +

+ {lang === "en" + ? "Points accumulate each match — the overall #1 wins ₩1,000,000." + : "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."} +

+ {/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */} +