내 지난 얘측 보기 기능 추가, 한국 멕시코 경기 응원가 1곡 추가

develop
jwkim 2026-06-18 15:41:39 +09:00
parent 0e5a63a28b
commit b4fc96f299
12 changed files with 257 additions and 8 deletions

View File

@ -11,12 +11,14 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from .config import settings from .config import settings
from .models import AIPrediction, CrowdStats, Match from .models import AIPrediction, CrowdStats, Match, UserPrediction
from .schemas import ( from .schemas import (
AIPredictionOut, AIPredictionOut,
CrowdStatsOut, CrowdStatsOut,
MatchOut, MatchOut,
MatchResult, MatchResult,
MyPredictionOut,
MyResultOut,
Team, Team,
) )
@ -116,6 +118,29 @@ def crowd_out(c: CrowdStats | None, match_id: str) -> CrowdStatsOut:
) )
def my_prediction_out(p: UserPrediction, m: Match) -> MyPredictionOut:
"""유저 픽 1건을 경기 정보와 합쳐 직렬화 (내 지난 예측 목록용)."""
result = None
if m.result_outcome is not None:
result = MyResultOut(
scoreA=m.result_score_a or 0,
scoreB=m.result_score_b or 0,
outcome=m.result_outcome, # type: ignore[arg-type]
hitOutcome=p.outcome == m.result_outcome,
)
return MyPredictionOut(
matchId=p.match_id,
teamA=_team_a(m),
teamB=_team_b(m),
kickoffKst=kst_iso(m.kickoff_at),
outcome=p.outcome, # type: ignore[arg-type]
scoreA=p.score_a,
scoreB=p.score_b,
submittedAt=kst_iso(p.updated_at or p.created_at),
result=result,
)
def match_out( def match_out(
m: Match, m: Match,
lang: str = "ko", lang: str = "ko",

View File

@ -7,10 +7,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from ..database import get_db from ..database import get_db
from ..domain import compute_phase, crowd_out, is_open_for_voting, is_votable from ..domain import (
compute_phase,
crowd_out,
is_open_for_voting,
is_votable,
my_prediction_out,
)
from ..models import AIPrediction, CrowdStats, Match, UserPrediction from ..models import AIPrediction, CrowdStats, Match, UserPrediction
from ..scoring import outcome_of from ..scoring import outcome_of
from ..schemas import SubmitPredictionIn, SubmitPredictionOut from ..schemas import MyPredictionOut, SubmitPredictionIn, SubmitPredictionOut
router = APIRouter(prefix="/api/predictions", tags=["predictions"]) router = APIRouter(prefix="/api/predictions", tags=["predictions"])
@ -36,6 +42,25 @@ async def _adjust_crowd(
) )
@router.get("/mine", response_model=list[MyPredictionOut])
async def my_predictions(
email: str, db: AsyncSession = Depends(get_db)
) -> list[MyPredictionOut]:
"""이메일 기준 내 지난 예측 목록 (최신 제출순). 로그인 없음 — 이메일이 신원."""
e = email.strip().lower()
if not e:
return []
rows = (
await db.execute(
select(UserPrediction, Match)
.join(Match, Match.match_id == UserPrediction.match_id)
.where(func.lower(UserPrediction.email) == e)
.order_by(UserPrediction.updated_at.desc())
)
).all()
return [my_prediction_out(up, m) for up, m in rows]
@router.post("", response_model=SubmitPredictionOut) @router.post("", response_model=SubmitPredictionOut)
async def submit_prediction( async def submit_prediction(
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db) body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)

View File

@ -90,6 +90,26 @@ class SubmitPredictionOut(BaseModel):
crowd: CrowdStatsOut crowd: CrowdStatsOut
# ── 내 지난 예측 (이메일 기준, 로그인 없음) ──────────────────
class MyResultOut(BaseModel):
scoreA: int
scoreB: int
outcome: Outcome
hitOutcome: bool # 내 outcome 이 실제 결과와 일치했는지
class MyPredictionOut(BaseModel):
matchId: str
teamA: Team
teamB: Team
kickoffKst: str
outcome: Outcome
scoreA: int
scoreB: int
submittedAt: str # ISO — 최신순 정렬 기준(updated_at)
result: MyResultOut | None = None # 경기 종료 시에만 채워짐
# ── 리더보드 ──────────────────────────────────────────────── # ── 리더보드 ────────────────────────────────────────────────
class StandingOut(BaseModel): class StandingOut(BaseModel):
rank: int rank: int

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@ -7,14 +7,28 @@ import type {
ModelName, ModelName,
Match, Match,
AIPrediction, AIPrediction,
MyPrediction,
Team, Team,
} from "@/lib/types"; } from "@/lib/types";
import { ApiError, getDeviceId, getRecentEmails, rememberEmail, submitPrediction } from "@/lib/api"; import {
ApiError,
fetchMyPredictions,
getDeviceId,
getRecentEmails,
rememberEmail,
submitPrediction,
} from "@/lib/api";
import Countdown from "./Countdown"; import Countdown from "./Countdown";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
type Step = "form" | "done"; type Step = "form" | "done";
// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨)
function fmtSubmitted(iso: string): string {
const m = iso.match(/\d{4}-(\d{2})-(\d{2})/);
return m ? `${+m[1]}.${+m[2]}` : "";
}
const MODEL_ICON: Record<ModelName, string> = { const MODEL_ICON: Record<ModelName, string> = {
GPT: "/icons/gpt.png", GPT: "/icons/gpt.png",
Claude: "/icons/claude.jpg", Claude: "/icons/claude.jpg",
@ -51,6 +65,8 @@ export default function Arena({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showRules, setShowRules] = useState(false); const [showRules, setShowRules] = useState(false);
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
const [votesExpanded, setVotesExpanded] = useState(false);
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스) // 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
useEffect(() => { useEffect(() => {
@ -59,6 +75,25 @@ export default function Arena({
); );
}, [scoreA, scoreB]); }, [scoreA, scoreB]);
// 재방문 시: 가장 최근 사용 이메일로 내 지난 예측 자동 로드 (로그인 대체).
// 이메일 칸은 비워둠 — develop UX(포커스 시 datalist 자동완성)를 유지.
useEffect(() => {
const last = getRecentEmails()[0];
if (last) fetchMyPredictions(last).then(setMyPreds).catch(() => {});
}, []);
// 이 경기에 대한 내 기존 픽 (있으면 스코어 프리필 + 목록에서 강조)
const myThisMatch = useMemo(
() => myPreds.find((p) => p.matchId === match.matchId),
[myPreds, match.matchId],
);
useEffect(() => {
if (myThisMatch) {
setScoreA(myThisMatch.scoreA);
setScoreB(myThisMatch.scoreB);
}
}, [myThisMatch]);
// 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨. // 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨.
const finished = !!match.result; const finished = !!match.result;
const disabled = finished || !match.votingOpen; const disabled = finished || !match.votingOpen;
@ -98,6 +133,8 @@ export default function Arena({
setCrowd(res.crowd); // 서버 권위 분포로 갱신 setCrowd(res.crowd); // 서버 권위 분포로 갱신
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억 rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
setRecentEmails(getRecentEmails()); setRecentEmails(getRecentEmails());
// 방금 제출 포함해 내 지난 예측 갱신(이번 경기 강조/upsert 반영)
fetchMyPredictions(email.trim()).then(setMyPreds).catch(() => {});
setStep("done"); setStep("done");
} catch (e) { } catch (e) {
const msg = const msg =
@ -304,7 +341,13 @@ export default function Arena({
list="tp-email-suggestions" list="tp-email-suggestions"
autoComplete="email" autoComplete="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
const v = e.target.value;
setEmail(v);
// 유효 이메일이면 그 즉시 내 지난 예측 조회(다른 기기/세션 흔적까지 표시)
if (EMAIL_RE.test(v.trim()))
fetchMyPredictions(v.trim()).then(setMyPreds).catch(() => {});
}}
placeholder={t.emailPh} placeholder={t.emailPh}
className="w-full rounded-2xl border border-[var(--share)] bg-[#0f1217] px-4 py-3 text-[16px] text-white shadow-[0_0_0_2px_rgba(166,94,255,0.12)] outline-none focus:shadow-[0_0_0_2px_rgba(166,94,255,0.3)]" className="w-full rounded-2xl border border-[var(--share)] bg-[#0f1217] px-4 py-3 text-[16px] text-white shadow-[0_0_0_2px_rgba(166,94,255,0.12)] outline-none focus:shadow-[0_0_0_2px_rgba(166,94,255,0.3)]"
/> />
@ -366,6 +409,79 @@ export default function Arena({
</div> </div>
)} )}
{/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */}
{myPreds.length > 0 && (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<div className="mb-3 flex items-baseline justify-between gap-2">
<h3 className="text-[16px] font-extrabold">{t.myVotesTitle}</h3>
<span className="shrink-0 text-[12px] text-[var(--ink-muted)]">
{t.myVotesSub}
</span>
</div>
<ul className="flex flex-col gap-2">
{(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => {
const isThis = p.matchId === match.matchId;
const pA = teamShort(p.teamA, lang);
const pB = teamShort(p.teamB, lang);
const pickTxt =
p.outcome === "TEAM_A_WIN"
? `${pA} ${t.win}`
: p.outcome === "TEAM_B_WIN"
? `${pB} ${t.win}`
: t.drawLabel;
return (
<li
key={p.matchId}
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
isThis
? "border-[var(--share)] bg-[rgba(166,94,255,0.1)]"
: "border-[var(--line-d)]"
}`}
>
<div className="min-w-0">
<div className="text-[15px] font-bold text-white">
{pA} {p.scoreA}-{p.scoreB} {pB}
</div>
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">
{pickTxt}
{isThis && (
<span className="text-[var(--share)]"> · {t.thisMatch}</span>
)}
</div>
</div>
{p.result ? (
<span
className={`shrink-0 text-[13px] font-bold ${
p.result.hitOutcome
? "text-[var(--share)]"
: "text-[var(--ink-muted)]"
}`}
>
{p.result.hitOutcome ? t.hit : t.miss}
</span>
) : (
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">
{fmtSubmitted(p.submittedAt)}
</span>
)}
</li>
);
})}
</ul>
{myPreds.length > 1 && (
<button
onClick={() => setVotesExpanded((v) => !v)}
className="mt-3 flex w-full items-center justify-center gap-1 rounded-xl border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-[var(--ink-muted)] transition active:scale-[0.99]"
>
{votesExpanded
? t.myVotesShowLess
: t.myVotesShowMore(myPreds.length - 1)}
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
</button>
)}
</section>
)}
{/* ===== 골드 상금 (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

@ -180,7 +180,7 @@ export default function Comments({
) : ( ) : (
<ul <ul
className={`mt-4 divide-y divide-[var(--line-d)] ${ className={`mt-4 divide-y divide-[var(--line-d)] ${
expanded ? "max-h-[360px] overflow-y-auto pr-1" : "" expanded ? "max-h-[360px] overflow-y-auto pr-1 scroll-dark" : ""
}`} }`}
> >
{visible.map((c, i) => ( {visible.map((c, i) => (

View File

@ -21,7 +21,7 @@
--gold-btn-2: #15a99b; /* 틸그린 */ --gold-btn-2: #15a99b; /* 틸그린 */
--gpt: #15a99b; /* 틸그린 (teal green) */ --gpt: #15a99b; /* 틸그린 (teal green) */
--claude: #e8814a; /* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
--gemini: #5b8cff; --gemini: #5b8cff;
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */ --share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
@ -152,6 +152,25 @@ body {
box-shadow: 0 6px 18px rgba(21, 169, 155, 0.3); box-shadow: 0 6px 18px rgba(21, 169, 155, 0.3);
} }
/* 다크 스크롤바 — 흰색 기본 스크롤바 제거 */
.scroll-dark {
scrollbar-width: thin;
scrollbar-color: var(--line-d) transparent;
}
.scroll-dark::-webkit-scrollbar {
width: 6px;
}
.scroll-dark::-webkit-scrollbar-track {
background: transparent;
}
.scroll-dark::-webkit-scrollbar-thumb {
background: var(--line-d);
border-radius: 9999px;
}
.scroll-dark::-webkit-scrollbar-thumb:hover {
background: #3a414e;
}
@keyframes barfill { @keyframes barfill {
from { from {
width: 0; width: 0;

View File

@ -6,6 +6,7 @@ import type {
Leaderboard, Leaderboard,
Match, Match,
ModelName, ModelName,
MyPrediction,
} from "./types"; } from "./types";
import type { Lang } from "./i18n"; import type { Lang } from "./i18n";
@ -73,6 +74,13 @@ export function getLeaderboard(): Promise<Leaderboard> {
return http<Leaderboard>(`/leaderboard`); return http<Leaderboard>(`/leaderboard`);
} }
// 내 지난 예측 — 이메일 기준 조회(로그인 없음). 최신순. 빈 이메일이면 빈 배열.
export function fetchMyPredictions(email: string): Promise<MyPrediction[]> {
const e = email.trim();
if (!e) return Promise.resolve([]);
return http<MyPrediction[]>(`/predictions/mine?email=${encodeURIComponent(e)}`);
}
// ── 댓글(경기별 한마디) — 완전 익명 ── // ── 댓글(경기별 한마디) — 완전 익명 ──
export function listComments( export function listComments(
matchId: string, matchId: string,

View File

@ -100,6 +100,12 @@ type Dict = {
footerDisc3: string; footerDisc3: string;
hook: (a: string, b: string) => string; hook: (a: string, b: string) => string;
moreMatches: string; moreMatches: string;
// 내 지난 예측 (이메일 기준)
myVotesTitle: string;
myVotesSub: string;
thisMatch: string;
myVotesShowMore: (n: number) => string;
myVotesShowLess: string;
commentsTitle: string; commentsTitle: string;
commentsCount: (n: number) => string; commentsCount: (n: number) => string;
commentPh: string; commentPh: string;
@ -193,6 +199,11 @@ export const DICT: Record<Lang, Dict> = {
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.", footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`, hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
moreMatches: "다른 경기 투표하기", moreMatches: "다른 경기 투표하기",
myVotesTitle: "내 지난 예측",
myVotesSub: "이메일 기준 · 최신순",
thisMatch: "이번 경기",
myVotesShowMore: (n) => `더보기 +${n}`,
myVotesShowLess: "접기",
commentsTitle: "한마디", commentsTitle: "한마디",
commentsCount: (n) => `${n}`, commentsCount: (n) => `${n}`,
commentPh: "응원 한마디 남기기...", commentPh: "응원 한마디 남기기...",
@ -283,6 +294,11 @@ export const DICT: Record<Lang, Dict> = {
footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.", footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.",
hook: (a, b) => `AI is split on ${a} vs ${b}`, hook: (a, b) => `AI is split on ${a} vs ${b}`,
moreMatches: "Vote on another match", moreMatches: "Vote on another match",
myVotesTitle: "Your past predictions",
myVotesSub: "By email · newest first",
thisMatch: "This match",
myVotesShowMore: (n) => `Show ${n} more`,
myVotesShowLess: "Show less",
commentsTitle: "Comments", commentsTitle: "Comments",
commentsCount: (n) => `${n} total`, commentsCount: (n) => `${n} total`,
commentPh: "Leave a comment...", commentPh: "Leave a comment...",

View File

@ -13,12 +13,14 @@ export type Track = {
// VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다. // VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다.
export const PATH_PLAYLISTS: Record<string, Track[]> = { export const PATH_PLAYLISTS: Record<string, Track[]> = {
"/match/A_MEX_KOR_20260619": [ "/match/A_MEX_KOR_20260619": [
{ title: "Red Wave 2026", artist: "aio2o", src: "/audio/Red Wave 2026.mp3", cover: "/thumbnail/Red Wave 2026.webp" }, { title: "El Tri vs 붉은 악마", artist: "aio2o", src: "/audio/Mexico vs Korea 2026.mp3", cover: "/thumbnail/Mexico vs Korea 2026.webp" },
{ title: "Red Wave: 멕시코를 넘어", artist: "aio2o", src: "/audio/Red Wave 2026.mp3", cover: "/thumbnail/Red Wave 2026.webp" },
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" }, { title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" }, { title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
], ],
"/match/A_RSA_KOR_20260625": [ "/match/A_RSA_KOR_20260625": [
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" }, { title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
], ],
}; };

View File

@ -62,6 +62,24 @@ export interface UserPick {
scoreB: number; scoreB: number;
} }
// 내 지난 예측 (이메일 기준, 로그인 없음) — GET /api/predictions/mine
export interface MyPrediction {
matchId: string;
teamA: Team;
teamB: Team;
kickoffKst: string;
outcome: Outcome;
scoreA: number;
scoreB: number;
submittedAt: string; // ISO — 최신순 정렬 기준
result?: {
scoreA: number;
scoreB: number;
outcome: Outcome;
hitOutcome: boolean;
} | null;
}
export interface Standing { export interface Standing {
rank: number; rank: number;
emailMasked: string; emailMasked: string;