누적 랭킹 보기 탭 추가

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 fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db from ..database import get_db
from ..models import Match, UserPoints from ..models import Match, UserPoints
from ..scoring import is_excluded from ..scoring import is_excluded, score_prediction
from ..schemas import LeaderboardOut, StandingOut from ..schemas import (
AILeaderboardOut,
AIStandingOut,
LeaderboardOut,
StandingOut,
)
router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"]) router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"])
@ -62,3 +68,47 @@ async def leaderboard(
for i, r in enumerate(ranked[:limit]) for i, r in enumerate(ranked[:limit])
] ]
return LeaderboardOut(standings=standings, scoredMatches=scored_matches) 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 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): class AdminAIPredictionIn(BaseModel):
matchId: str matchId: str

View File

@ -19,6 +19,7 @@ import {
submitPrediction, submitPrediction,
} from "@/lib/api"; } from "@/lib/api";
import Countdown from "./Countdown"; import Countdown from "./Countdown";
import RankingBoard from "./RankingBoard";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
type Step = "form" | "done"; type Step = "form" | "done";
@ -457,6 +458,9 @@ export default function Arena({
</section> </section>
)} )}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */}
<RankingBoard lang={lang} />
{/* ===== 골드 상금 (003) ===== */} {/* ===== 골드 상금 (003) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5"> <section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3"> <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). // 백엔드 FastAPI 클라이언트. 동일 출처 /api (dev=vite proxy, prod=nginx proxy).
import type { import type {
AILeaderboard,
Comment, Comment,
CommentList, CommentList,
CrowdStats, CrowdStats,
@ -7,6 +8,8 @@ import type {
Match, Match,
ModelName, ModelName,
MyPrediction, MyPrediction,
RankingEntry,
RankingTab,
} from "./types"; } from "./types";
import type { Lang } from "./i18n"; import type { Lang } from "./i18n";
@ -74,6 +77,24 @@ export function getLeaderboard(): Promise<Leaderboard> {
return http<Leaderboard>(`/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[]> { export function fetchMyPredictions(email: string): Promise<MyPrediction[]> {
const e = email.trim(); const e = email.trim();

View File

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

View File

@ -93,6 +93,26 @@ export interface Leaderboard {
scoredMatches: number; 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 { export interface Comment {
nickname: string; // 축구 코믹 한국어 5글자 (서버가 기기 해시로 자동 배정) nickname: string; // 축구 코믹 한국어 5글자 (서버가 기기 해시로 자동 배정)
body: string; body: string;

View File

@ -1,72 +1,25 @@
import { useEffect, useState } from "react";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import RankingBoard from "@/components/RankingBoard";
import { useLang } from "@/lib/useLang"; 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() { export default function Leaderboard() {
const lang = useLang(); 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 ( return (
<main className="shell"> <main className="shell">
<Hero back lang={lang} /> <Hero back lang={lang} />
<section className="mt-8"> <section className="mt-8">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div> <div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
{empty ? ( {lang === "en"
<div className="mt-4 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center"> ? "Points accumulate each match — the overall #1 wins ₩1,000,000."
<p className="text-[15px] font-bold"> : "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}
{lang === "en" ? "Rankings open soon" : "랭킹은 곧 공개됩니다"} </p>
</p> {/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]"> <RankingBoard lang={lang} defaultOpen />
{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>
)}
</section> </section>
<Footer lang={lang} /> <Footer lang={lang} />
</main> </main>