Load all 12 groups' schedule (72 matches) + Naver-style schedule UI

Backend
- teams_data.py: 48-team data (ko/short/en), TLA→FIFA code remap
- config: schedule_all_groups, featured_group("A") — only featured group is votable/AI-predicted
- schedule_fetch: fetch all GROUP_STAGE matches from football-data, KST serialize
- schedule_sync: ingest all groups, unordered-pair match to avoid Group A dup, derive votable from group
- worker: AI generation limited to featured group; settle still covers all
- domain/predictions: is_votable() + MatchOut.votable; reject non-votable submits with 403 MATCH_NOT_VOTABLE

Frontend
- types: Match.votable
- ScheduleBoard: Naver-style date/group tabs + chips; votable(A) cards link to detail with AI split + 참여수, others display-only

Verified: 72 matches (12×6), Group A not duplicated, votable A=6/others=66; A submit 200, B submit 403

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
develop
hbyang 2026-06-12 14:56:24 +09:00
parent c9244bc42d
commit deb1332440
10 changed files with 366 additions and 99 deletions

View File

@ -66,7 +66,11 @@ class Settings(BaseSettings):
schedule_url: str = (
"https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json"
)
schedule_group: str = "Group A" # 추적할 그룹 (openfootball 라벨)
schedule_group: str = "Group A" # (구) openfootball 단일 그룹 라벨 — 폴백용
# 전체 조별리그(12개조 72경기) 일정을 불러올지. 결과/스코어는 전 경기 표시.
schedule_all_groups: bool = True
# AI 예측·투표 대상 조(이 조만 예측 생성·투표 가능). 그 외는 일정/결과만 표시.
featured_group: str = "A"
football_data_token: str = "" # football-data 사용 시 토큰
football_data_competition: str = "WC"
# 결과(스코어) 소스 — 일정 소스와 분리. 빈값이면 schedule_source 사용.

View File

@ -58,9 +58,16 @@ def compute_phase(m: Match, now: datetime | None = None) -> str:
return "open"
def is_votable(m: Match) -> bool:
"""AI 예측·투표 대상 조인지. featured_group 만 투표 가능, 그 외는 일정/결과만."""
return m.group == settings.featured_group
def is_open_for_voting(m: Match, now: datetime | None = None) -> bool:
"""제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈."""
now = now or now_utc()
if not is_votable(m):
return False
if m.result_outcome is not None:
return False
if now >= ensure_aware(m.lock_at):
@ -141,6 +148,7 @@ def match_out(
lockAt=kst_iso(m.lock_at),
status=phase,
phase=phase,
votable=is_votable(m),
votingOpen=is_open_for_voting(m, now),
hookText=m.hook_text,
result=result,

View File

@ -7,7 +7,7 @@ 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
from ..domain import compute_phase, crowd_out, is_open_for_voting, is_votable
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
from ..scoring import outcome_of
from ..schemas import SubmitPredictionIn, SubmitPredictionOut
@ -49,6 +49,8 @@ async def submit_prediction(
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if not is_votable(match):
raise HTTPException(status_code=403, detail="MATCH_NOT_VOTABLE")
if not is_open_for_voting(match):
# 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전
phase = compute_phase(match)

View File

@ -57,6 +57,7 @@ class MatchOut(BaseModel):
lockAt: str
status: str # = phase (실시간 계산값으로 통일)
phase: str # scheduled | open | locked | live | finished (now 기준 계산)
votable: bool # AI 예측·투표 대상 조인지 (그 외는 일정/결과만)
votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영)
hookText: str
result: MatchResult | None = None

View File

@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone
from ..config import settings
from ..schedule_data import code_for
from ..teams_data import code_for_tla
log = logging.getLogger("triplepick.schedule")
@ -95,8 +96,63 @@ async def _fetch_football_data() -> list[dict]:
return out
async def _fetch_all_groups_football_data() -> list[dict]:
"""football-data 에서 전체 조별리그(12개조 72경기) 수집.
반환: [{teamA, teamB, group('A'..'L'), kickoffKst, venue}] (tla코드)
"""
if not settings.football_data_token:
log.warning("schedule(all): football-data 토큰 미설정 — 수집 생략")
return []
import httpx
url = (
f"https://api.football-data.org/v4/competitions/"
f"{settings.football_data_competition}/matches"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers={"X-Auth-Token": settings.football_data_token})
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
if m.get("stage") != "GROUP_STAGE":
continue
g = (m.get("group") or "").replace("GROUP_", "") # 'A'..'L'
ht = (m.get("homeTeam") or {}).get("tla")
at = (m.get("awayTeam") or {}).get("tla")
if not ht or not at:
continue
utc = m.get("utcDate")
if not utc:
continue
kickoff = (
datetime.fromisoformat(utc.replace("Z", "+00:00")).astimezone(KST).isoformat()
)
out.append(
{
"teamA": code_for_tla(ht),
"teamB": code_for_tla(at),
"group": g,
"kickoffKst": kickoff,
"venue": m.get("venue") or "",
}
)
return out
async def fetch_schedule() -> list[dict]:
"""소스에 따라 일정 수집. 실패 시 빈 리스트(기존 일정 유지)."""
"""일정 수집. 전체 조 모드면 football-data 전 조별리그, 아니면 단일 조."""
if settings.schedule_all_groups:
try:
records = await _fetch_all_groups_football_data()
log.info("schedule: 전체 조별리그 %d경기 수집", len(records))
if records:
return records
except Exception as e: # noqa: BLE001
log.error("schedule(all) 실패: %s — 단일 조 폴백", e)
src = settings.schedule_source.lower()
try:
if src == "openfootball":

View File

@ -1,21 +1,25 @@
"""수집한 일정을 DB에 반영 — 워커(스케줄링 서버)가 매일 호출.
매칭 = (teamA_code, teamB_code) 순서쌍 (그룹 라운드로빈에서 유일).
- 기존 경기: 킥오프/venue/투표시간(opens·lock) 갱신. 결과·예측·crowd·라운드/후킹은 보존.
- 신규 경기: matchId 생성 + crowd 초기화하여 삽입(예측은 워커 AI 생성이 채움).
전체 조별리그 모드: 12개조 72경기를 적재. 결과/스코어는 경기 표시.
- 기존 경기: (팀쌍, 순서 무관) 매칭 킥오프/venue/투표시간 갱신. 결과·예측·crowd·팀명·라운드 보존.
- 신규 경기: teams_data(48개국) 구성 + matchId 생성 + crowd 초기화하여 삽입.
종료(result) 경기는 시각 변경하지 않음.
"""
from __future__ import annotations
import logging
from datetime import timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import CrowdStats, Match
from ..schedule_data import TEAMS, lock_at, opens_at, parse_kickoff
from ..schedule_data import lock_at, opens_at, parse_kickoff
from ..teams_data import team_info
log = logging.getLogger("triplepick.schedule")
KST = timezone(timedelta(hours=9))
async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
@ -23,11 +27,16 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
return {"updated": 0, "inserted": 0, "skipped": 0}
existing = (await db.execute(select(Match))).scalars().all()
by_pair = {(m.team_a_code, m.team_b_code): m for m in existing}
# 순서 무관 매칭 — 소스의 홈/원정 순서가 우리와 달라도 기존 경기를 찾음(중복 방지)
by_pair: dict[tuple, Match] = {}
for m in existing:
by_pair[(m.team_a_code, m.team_b_code)] = m
by_pair[(m.team_b_code, m.team_a_code)] = m
updated = inserted = skipped = 0
for rec in records:
a, b = rec["teamA"], rec["teamB"]
group = rec.get("group", settings.featured_group)
kickoff = parse_kickoff(rec["kickoffKst"])
m = by_pair.get((a, b))
if m is not None:
@ -41,29 +50,28 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
m.venue = rec["venue"]
updated += 1
else:
ta, tb = TEAMS.get(a), TEAMS.get(b)
if not ta or not tb:
skipped += 1
continue
match_id = f"A_{a}_{b}_{kickoff.astimezone().strftime('%Y%m%d')}"
db.add(
Match(
ta, tb = team_info(a), team_info(b)
kst_date = kickoff.astimezone(KST).strftime("%Y%m%d")
match_id = f"{group}_{a}_{b}_{kst_date}"
new = Match(
match_id=match_id,
round_label="Match",
group="A",
round_label="",
group=group,
team_a_name=ta["name"], team_a_short=ta["shortName"],
team_a_code=ta["code"], team_a_flag=ta["flag"],
team_b_name=tb["name"], team_b_short=tb["shortName"],
team_b_code=tb["code"], team_b_flag=tb["flag"],
venue=rec.get("venue", ""),
hook_text=f"{ta['shortName']} vs {tb['shortName']}, AI의 예측은",
hook_text=f"{ta['shortName']} vs {tb['shortName']}",
kickoff_at=kickoff,
opens_at=opens_at(kickoff),
lock_at=lock_at(kickoff),
status="scheduled",
)
)
db.add(new)
db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0))
by_pair[(a, b)] = new
by_pair[(b, a)] = new
inserted += 1
await db.commit()

76
backend/app/teams_data.py Normal file
View File

@ -0,0 +1,76 @@
"""2026 월드컵 48개국 팀 데이터 — 코드(대문자 FIFA) → 한글/약식/영문.
국기는 frontend /assets/flags/{code소문자}.svg 사용(48 자산 보유).
football-data tla 코드 변환은 TLA_REMAP(대부분 동일, CUR/URY만 예외).
"""
from __future__ import annotations
# football-data tla → 내부 코드(=FIFA 국기 코드). 대부분 동일, 예외만 매핑.
TLA_REMAP = {"CUR": "CUW", "URY": "URU"}
def code_for_tla(tla: str) -> str:
tla = (tla or "").strip().upper()
return TLA_REMAP.get(tla, tla)
# 코드 → {ko(전체명), short(약식), en}
TEAMS: dict[str, dict] = {
"ALG": {"ko": "알제리", "short": "알제리", "en": "Algeria"},
"ARG": {"ko": "아르헨티나", "short": "아르헨티나", "en": "Argentina"},
"AUS": {"ko": "호주", "short": "호주", "en": "Australia"},
"AUT": {"ko": "오스트리아", "short": "오스트리아", "en": "Austria"},
"BEL": {"ko": "벨기에", "short": "벨기에", "en": "Belgium"},
"BIH": {"ko": "보스니아 헤르체고비나", "short": "보스니아", "en": "Bosnia-Herzegovina"},
"BRA": {"ko": "브라질", "short": "브라질", "en": "Brazil"},
"CAN": {"ko": "캐나다", "short": "캐나다", "en": "Canada"},
"CIV": {"ko": "코트디부아르", "short": "코트디부아르", "en": "Ivory Coast"},
"COD": {"ko": "콩고민주공화국", "short": "콩고DR", "en": "Congo DR"},
"COL": {"ko": "콜롬비아", "short": "콜롬비아", "en": "Colombia"},
"CPV": {"ko": "카보베르데", "short": "카보베르데", "en": "Cape Verde"},
"CRO": {"ko": "크로아티아", "short": "크로아티아", "en": "Croatia"},
"CUW": {"ko": "퀴라소", "short": "퀴라소", "en": "Curaçao"},
"CZE": {"ko": "체코", "short": "체코", "en": "Czechia"},
"ECU": {"ko": "에콰도르", "short": "에콰도르", "en": "Ecuador"},
"EGY": {"ko": "이집트", "short": "이집트", "en": "Egypt"},
"ENG": {"ko": "잉글랜드", "short": "잉글랜드", "en": "England"},
"ESP": {"ko": "스페인", "short": "스페인", "en": "Spain"},
"FRA": {"ko": "프랑스", "short": "프랑스", "en": "France"},
"GER": {"ko": "독일", "short": "독일", "en": "Germany"},
"GHA": {"ko": "가나", "short": "가나", "en": "Ghana"},
"HAI": {"ko": "아이티", "short": "아이티", "en": "Haiti"},
"IRN": {"ko": "이란", "short": "이란", "en": "Iran"},
"IRQ": {"ko": "이라크", "short": "이라크", "en": "Iraq"},
"JOR": {"ko": "요르단", "short": "요르단", "en": "Jordan"},
"JPN": {"ko": "일본", "short": "일본", "en": "Japan"},
"KOR": {"ko": "대한민국", "short": "한국", "en": "Korea Republic"},
"KSA": {"ko": "사우디아라비아", "short": "사우디", "en": "Saudi Arabia"},
"MAR": {"ko": "모로코", "short": "모로코", "en": "Morocco"},
"MEX": {"ko": "멕시코", "short": "멕시코", "en": "Mexico"},
"NED": {"ko": "네덜란드", "short": "네덜란드", "en": "Netherlands"},
"NOR": {"ko": "노르웨이", "short": "노르웨이", "en": "Norway"},
"NZL": {"ko": "뉴질랜드", "short": "뉴질랜드", "en": "New Zealand"},
"PAN": {"ko": "파나마", "short": "파나마", "en": "Panama"},
"PAR": {"ko": "파라과이", "short": "파라과이", "en": "Paraguay"},
"POR": {"ko": "포르투갈", "short": "포르투갈", "en": "Portugal"},
"QAT": {"ko": "카타르", "short": "카타르", "en": "Qatar"},
"RSA": {"ko": "남아프리카 공화국", "short": "남아공", "en": "South Africa"},
"SCO": {"ko": "스코틀랜드", "short": "스코틀랜드", "en": "Scotland"},
"SEN": {"ko": "세네갈", "short": "세네갈", "en": "Senegal"},
"SUI": {"ko": "스위스", "short": "스위스", "en": "Switzerland"},
"SWE": {"ko": "스웨덴", "short": "스웨덴", "en": "Sweden"},
"TUN": {"ko": "튀니지", "short": "튀니지", "en": "Tunisia"},
"TUR": {"ko": "튀르키예", "short": "튀르키예", "en": "Turkey"},
"URU": {"ko": "우루과이", "short": "우루과이", "en": "Uruguay"},
"USA": {"ko": "미국", "short": "미국", "en": "United States"},
"UZB": {"ko": "우즈베키스탄", "short": "우즈벡", "en": "Uzbekistan"},
}
def team_info(code: str) -> dict:
c = (code or "").strip().upper()
t = TEAMS.get(c)
if t:
return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": ""}
# 미등록 코드 폴백
return {"name": c, "shortName": c, "code": c, "flag": ""}

View File

@ -83,10 +83,14 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
only_missing=False: 전부 재생성(덮어쓰기) 수동 재생성 스크립트용.
"""
async with SessionLocal() as db:
# AI 예측·투표 대상 조(featured_group)만 생성. 그 외 조는 일정/결과만.
matches = (
await db.execute(
select(Match)
.where(Match.result_outcome.is_(None))
.where(
Match.result_outcome.is_(None),
Match.group == settings.featured_group,
)
.options(selectinload(Match.predictions))
)
).scalars().all()

View File

@ -1,31 +1,28 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { dateHeading, timeOnly } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
import { timeOnly, dateKey } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n";
import type { Match, MatchPhase } from "@/lib/types";
import { dateKey } from "@/lib/format";
import TeamFlag from "./TeamFlag";
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
// 칩용 짧은 날짜: "6.12 (금)" / "Jun 12 (Fri)"
function shortDate(iso: string, lang: Lang): string {
const m = iso.match(/(\d{4})-(\d{2})-(\d{2})/);
if (!m) return iso;
const [, y, mo, d] = m;
const dow = dayName(lang, new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay());
return lang === "en" ? `${monthEn(+mo)} ${+d} (${dow})` : `${+mo}.${+d} (${dow})`;
}
const PHASE_CLS: Record<MatchPhase, string> = {
open: "border-[#94FBE0] text-[#94FBE0]",
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]",
locked: "border-[var(--line-d)] text-white/45", // 투표 종료 — 비활성 톤
live: "border-[#FF5B5B] text-[#FF5B5B]", // 경기중 — 라이브 강조
locked: "border-[var(--line-d)] text-white/45",
live: "border-[#FF5B5B] text-[#FF5B5B]",
finished: "border-white/15 text-white/55",
};
// 날짜별로 묶고 킥오프 오름차순 정렬 (백엔드가 이미 정렬 반환)
function groupByDate(matches: Match[]): { date: string; matches: Match[] }[] {
const groups: { date: string; matches: Match[] }[] = [];
for (const m of matches) {
const key = dateKey(m.kickoffKst);
const last = groups[groups.length - 1];
if (last && last.date === key) last.matches.push(m);
else groups.push({ date: key, matches: [m] });
}
return groups;
}
type Tab = "date" | "group";
export default function ScheduleBoard({
matches,
@ -35,67 +32,138 @@ export default function ScheduleBoard({
lang?: Lang;
}) {
const t = dict(lang);
const groups = groupByDate(matches);
const [tab, setTab] = useState<Tab>("date");
// 날짜·조 목록 (정렬)
const dates = useMemo(
() => Array.from(new Set(matches.map((m) => dateKey(m.kickoffKst)))).sort(),
[matches],
);
const groups = useMemo(
() => Array.from(new Set(matches.map((m) => m.group))).sort(),
[matches],
);
// 기본 선택: 오늘(없으면 첫 날짜) / A조
const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
const [selDate, setSelDate] = useState(
() => (dates.includes(todayKey) ? todayKey : dates[0]) ?? "",
);
const [selGroup, setSelGroup] = useState(
() => (groups.includes("A") ? "A" : groups[0]) ?? "",
);
const shown = useMemo(() => {
const list =
tab === "date"
? matches.filter((m) => dateKey(m.kickoffKst) === selDate)
: matches.filter((m) => m.group === selGroup);
return [...list].sort(
(a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(),
);
}, [matches, tab, selDate, selGroup]);
const tabLabel = (k: Tab) =>
k === "date" ? (lang === "en" ? "By date" : "날짜별") : lang === "en" ? "Groups" : "조별리그";
return (
<section className="mt-6">
<div className="mb-3 flex items-baseline gap-2.5">
<h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2>
<span className="whitespace-nowrap text-[12px] text-white/55">
{t.schedGuide}
</span>
<span className="whitespace-nowrap text-[12px] text-white/55">{t.schedGuide}</span>
</div>
<div className="flex flex-col gap-5">
{groups.map((g) => (
<div key={g.date}>
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}>
{dateHeading(g.date, lang)}
</div>
<div className="flex flex-col gap-2.5">
{g.matches.map((m) => (
<MatchCard key={m.matchId} match={m} lang={lang} />
{/* 탭: 날짜별 / 조별리그 */}
<div className="mb-3 flex gap-5 border-b border-[var(--line-d)]">
{(["date", "group"] as Tab[]).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={`-mb-px border-b-2 pb-2 text-[15px] font-bold transition ${
tab === k
? "border-[var(--green)] text-white"
: "border-transparent text-white/45"
}`}
>
{tabLabel(k)}
</button>
))}
</div>
{/* 칩: 날짜 또는 조 */}
<div className="mb-4 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{tab === "date"
? dates.map((d) => (
<Chip key={d} active={d === selDate} onClick={() => setSelDate(d)}>
{shortDate(d, lang)}
</Chip>
))
: groups.map((g) => (
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
{lang === "en" ? `Group ${g}` : `${g}`}
</Chip>
))}
</div>
{/* 경기 카드 */}
<div className="flex flex-col gap-2.5">
{shown.length === 0 && (
<p className="py-6 text-center text-[13px] text-white/45">
{lang === "en" ? "No matches" : "경기가 없습니다"}
</p>
)}
{shown.map((m) => (
<MatchCard key={m.matchId} match={m} lang={lang} />
))}
</div>
</section>
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`shrink-0 whitespace-nowrap rounded-full border px-3.5 py-1.5 text-[13px] font-bold transition ${
active
? "border-[#94FBE0] text-[#94FBE0]"
: "border-[var(--line-d)] text-white/55"
}`}
>
{children}
</button>
);
}
function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
const t = dict(lang);
const phase = match.phase;
const preds = match.predictions;
const crowdTotal = match.crowd?.total ?? 0;
const finished = !!match.result;
// AI 픽 갈림 요약
const tally = { a: 0, d: 0, b: 0 };
for (const p of preds) {
if (p.outcome === "TEAM_A_WIN") tally.a++;
else if (p.outcome === "DRAW") tally.d++;
else tally.b++;
}
const split: string[] = [];
if (tally.a) split.push(`${teamShort(match.teamA, lang)} ${tally.a}`);
if (tally.d) split.push(`${t.draw} ${tally.d}`);
if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`;
return (
<Link
to={href}
className="block rounded-2xl border-2 border-[#94FBE0]/45 bg-[#171b21] p-4 transition active:scale-[0.99] hover:border-[#94FBE0]"
>
// 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동
const inner = (
<>
<div className="flex items-center justify-between text-[11px]">
<span className="font-mono font-bold text-white/70">
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "}
{tRound(match.roundLabel, lang)}
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span>
{match.roundLabel ? <> · {tRound(match.roundLabel, lang)}</> : null}
</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-white/45">
{lang === "en" ? `Group ${match.group}` : `${match.group}`}
</span>
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{t.phase[phase]}
</span>
</span>
</div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
@ -104,7 +172,6 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
</div>
{match.result ? (
// 종료된 경기 — 최종 스코어 (예: 3 : 2)
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
{match.result.scoreA}
<span className="px-1.5 text-white/40">:</span>
@ -119,15 +186,55 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
</div>
</div>
{/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 */}
{match.votable && (
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
<span>
<span className="font-semibold text-white/55">{t.aiPicks}</span> {split.join(" · ")}
<span className="font-semibold text-white/55">{t.aiPicks}</span>{" "}
{aiSplit(match, lang).join(" · ")}
</span>
<span className="flex items-center gap-2">
<span>{t.joined(crowdTotal.toLocaleString())}</span>
<span>{t.joined((match.crowd?.total ?? 0).toLocaleString())}</span>
<span className="text-[#94FBE0]"></span>
</span>
</div>
)}
</>
);
const base = "block rounded-2xl border-2 bg-[#171b21] p-4";
if (match.votable && !finished) {
return (
<Link
to={`/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`}
className={`${base} border-[#94FBE0]/45 transition active:scale-[0.99] hover:border-[#94FBE0]`}
>
{inner}
</Link>
);
}
// 종료된 투표 경기는 결과 보기로, 비투표 경기는 표시 전용(클릭 시 상세=결과)
if (match.votable) {
return (
<Link to={`/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`} className={`${base} border-white/12`}>
{inner}
</Link>
);
}
return <div className={`${base} border-[var(--line-d)]`}>{inner}</div>;
}
function aiSplit(match: Match, lang: Lang): string[] {
const t = dict(lang);
const tally = { a: 0, d: 0, b: 0 };
for (const p of match.predictions) {
if (p.outcome === "TEAM_A_WIN") tally.a++;
else if (p.outcome === "DRAW") tally.d++;
else tally.b++;
}
const out: string[] = [];
if (tally.a) out.push(`${teamShort(match.teamA, lang)} ${tally.a}`);
if (tally.d) out.push(`${t.draw} ${tally.d}`);
if (tally.b) out.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
return out.length ? out : ["-"];
}

View File

@ -48,6 +48,7 @@ export interface Match {
lockAt: string;
status: string;
phase: MatchPhase;
votable: boolean;
votingOpen: boolean;
hookText: string;
result?: MatchResult | null;