내 지난 얘측 보기 기능 추가, 한국 멕시코 경기 응원가 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 .config import settings
from .models import AIPrediction, CrowdStats, Match
from .models import AIPrediction, CrowdStats, Match, UserPrediction
from .schemas import (
AIPredictionOut,
CrowdStatsOut,
MatchOut,
MatchResult,
MyPredictionOut,
MyResultOut,
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(
m: Match,
lang: str = "ko",

View File

@ -7,10 +7,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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 ..scoring import outcome_of
from ..schemas import SubmitPredictionIn, SubmitPredictionOut
from ..schemas import MyPredictionOut, SubmitPredictionIn, SubmitPredictionOut
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)
async def submit_prediction(
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)

View File

@ -90,6 +90,26 @@ class SubmitPredictionOut(BaseModel):
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):
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,
Match,
AIPrediction,
MyPrediction,
Team,
} 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 TeamFlag from "./TeamFlag";
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> = {
GPT: "/icons/gpt.png",
Claude: "/icons/claude.jpg",
@ -51,6 +65,8 @@ export default function Arena({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRules, setShowRules] = useState(false);
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
const [votesExpanded, setVotesExpanded] = useState(false);
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
useEffect(() => {
@ -59,6 +75,25 @@ export default function Arena({
);
}, [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 라벨.
const finished = !!match.result;
const disabled = finished || !match.votingOpen;
@ -98,6 +133,8 @@ export default function Arena({
setCrowd(res.crowd); // 서버 권위 분포로 갱신
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
setRecentEmails(getRecentEmails());
// 방금 제출 포함해 내 지난 예측 갱신(이번 경기 강조/upsert 반영)
fetchMyPredictions(email.trim()).then(setMyPreds).catch(() => {});
setStep("done");
} catch (e) {
const msg =
@ -304,7 +341,13 @@ export default function Arena({
list="tp-email-suggestions"
autoComplete="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}
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>
)}
{/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */}
{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) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3">

View File

@ -180,7 +180,7 @@ export default function Comments({
) : (
<ul
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) => (

View File

@ -21,7 +21,7 @@
--gold-btn-2: #15a99b; /* 틸그린 */
--gpt: #15a99b; /* 틸그린 (teal green) */
--claude: #e8814a;
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
--gemini: #5b8cff;
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
@ -152,6 +152,25 @@ body {
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 {
from {
width: 0;

View File

@ -6,6 +6,7 @@ import type {
Leaderboard,
Match,
ModelName,
MyPrediction,
} from "./types";
import type { Lang } from "./i18n";
@ -73,6 +74,13 @@ export function getLeaderboard(): Promise<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(
matchId: string,

View File

@ -100,6 +100,12 @@ type Dict = {
footerDisc3: string;
hook: (a: string, b: string) => string;
moreMatches: string;
// 내 지난 예측 (이메일 기준)
myVotesTitle: string;
myVotesSub: string;
thisMatch: string;
myVotesShowMore: (n: number) => string;
myVotesShowLess: string;
commentsTitle: string;
commentsCount: (n: number) => string;
commentPh: string;
@ -193,6 +199,11 @@ export const DICT: Record<Lang, Dict> = {
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
moreMatches: "다른 경기 투표하기",
myVotesTitle: "내 지난 예측",
myVotesSub: "이메일 기준 · 최신순",
thisMatch: "이번 경기",
myVotesShowMore: (n) => `더보기 +${n}`,
myVotesShowLess: "접기",
commentsTitle: "한마디",
commentsCount: (n) => `${n}`,
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.",
hook: (a, b) => `AI is split on ${a} vs ${b}`,
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",
commentsCount: (n) => `${n} total`,
commentPh: "Leave a comment...",

View File

@ -13,12 +13,14 @@ export type Track = {
// VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다.
export const PATH_PLAYLISTS: Record<string, Track[]> = {
"/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: "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": [
{ 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;
}
// 내 지난 예측 (이메일 기준, 로그인 없음) — 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 {
rank: number;
emailMasked: string;