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 = ( schedule_url: str = (
"https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json" "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_token: str = "" # football-data 사용 시 토큰
football_data_competition: str = "WC" football_data_competition: str = "WC"
# 결과(스코어) 소스 — 일정 소스와 분리. 빈값이면 schedule_source 사용. # 결과(스코어) 소스 — 일정 소스와 분리. 빈값이면 schedule_source 사용.

View File

@ -58,9 +58,16 @@ def compute_phase(m: Match, now: datetime | None = None) -> str:
return "open" 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: def is_open_for_voting(m: Match, now: datetime | None = None) -> bool:
"""제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈.""" """제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈."""
now = now or now_utc() now = now or now_utc()
if not is_votable(m):
return False
if m.result_outcome is not None: if m.result_outcome is not None:
return False return False
if now >= ensure_aware(m.lock_at): if now >= ensure_aware(m.lock_at):
@ -141,6 +148,7 @@ def match_out(
lockAt=kst_iso(m.lock_at), lockAt=kst_iso(m.lock_at),
status=phase, status=phase,
phase=phase, phase=phase,
votable=is_votable(m),
votingOpen=is_open_for_voting(m, now), votingOpen=is_open_for_voting(m, now),
hookText=m.hook_text, hookText=m.hook_text,
result=result, result=result,

View File

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

View File

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

View File

@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone
from ..config import settings from ..config import settings
from ..schedule_data import code_for from ..schedule_data import code_for
from ..teams_data import code_for_tla
log = logging.getLogger("triplepick.schedule") log = logging.getLogger("triplepick.schedule")
@ -95,8 +96,63 @@ async def _fetch_football_data() -> list[dict]:
return out 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]: 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() src = settings.schedule_source.lower()
try: try:
if src == "openfootball": if src == "openfootball":

View File

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

View File

@ -1,31 +1,28 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { dateHeading, timeOnly } from "@/lib/format"; import { timeOnly, dateKey } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n"; import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n";
import type { Match, MatchPhase } from "@/lib/types"; import type { Match, MatchPhase } from "@/lib/types";
import { dateKey } from "@/lib/format";
import TeamFlag from "./TeamFlag"; 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> = { const PHASE_CLS: Record<MatchPhase, string> = {
open: "border-[#94FBE0] text-[#94FBE0]", open: "border-[#94FBE0] text-[#94FBE0]",
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]", scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]",
locked: "border-[var(--line-d)] text-white/45", // 투표 종료 — 비활성 톤 locked: "border-[var(--line-d)] text-white/45",
live: "border-[#FF5B5B] text-[#FF5B5B]", // 경기중 — 라이브 강조 live: "border-[#FF5B5B] text-[#FF5B5B]",
finished: "border-white/15 text-white/55", finished: "border-white/15 text-white/55",
}; };
// 날짜별로 묶고 킥오프 오름차순 정렬 (백엔드가 이미 정렬 반환) type Tab = "date" | "group";
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;
}
export default function ScheduleBoard({ export default function ScheduleBoard({
matches, matches,
@ -35,67 +32,138 @@ export default function ScheduleBoard({
lang?: Lang; lang?: Lang;
}) { }) {
const t = dict(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 ( return (
<section className="mt-6"> <section className="mt-6">
<div className="mb-3 flex items-baseline gap-2.5"> <div className="mb-3 flex items-baseline gap-2.5">
<h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2> <h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2>
<span className="whitespace-nowrap text-[12px] text-white/55"> <span className="whitespace-nowrap text-[12px] text-white/55">{t.schedGuide}</span>
{t.schedGuide}
</span>
</div> </div>
<div className="flex flex-col gap-5"> {/* 탭: 날짜별 / 조별리그 */}
{groups.map((g) => ( <div className="mb-3 flex gap-5 border-b border-[var(--line-d)]">
<div key={g.date}> {(["date", "group"] as Tab[]).map((k) => (
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}> <button
{dateHeading(g.date, lang)} key={k}
</div> onClick={() => setTab(k)}
<div className="flex flex-col gap-2.5"> className={`-mb-px border-b-2 pb-2 text-[15px] font-bold transition ${
{g.matches.map((m) => ( tab === k
<MatchCard key={m.matchId} match={m} lang={lang} /> ? "border-[var(--green)] text-white"
: "border-transparent text-white/45"
}`}
>
{tabLabel(k)}
</button>
))} ))}
</div> </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>
{/* 경기 카드 */}
<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> </div>
</section> </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 }) { function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
const t = dict(lang); const t = dict(lang);
const phase = match.phase; const phase = match.phase;
const preds = match.predictions; const finished = !!match.result;
const crowdTotal = match.crowd?.total ?? 0;
// AI 픽 갈림 요약 // 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동
const tally = { a: 0, d: 0, b: 0 }; const inner = (
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]"
>
<div className="flex items-center justify-between text-[11px]"> <div className="flex items-center justify-between text-[11px]">
<span className="font-mono font-bold text-white/70"> <span className="font-mono font-bold text-white/70">
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "} {timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span>
{tRound(match.roundLabel, lang)} {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>
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}> <span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{t.phase[phase]} {t.phase[phase]}
</span> </span>
</span>
</div> </div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2"> <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> <span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
</div> </div>
{match.result ? ( {match.result ? (
// 종료된 경기 — 최종 스코어 (예: 3 : 2)
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white"> <span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
{match.result.scoreA} {match.result.scoreA}
<span className="px-1.5 text-white/40">:</span> <span className="px-1.5 text-white/40">:</span>
@ -119,15 +186,55 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
</div> </div>
</div> </div>
{/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 */}
{match.votable && (
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]"> <div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
<span> <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>
<span className="flex items-center gap-2"> <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 className="text-[#94FBE0]"></span>
</span> </span>
</div> </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> </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; lockAt: string;
status: string; status: string;
phase: MatchPhase; phase: MatchPhase;
votable: boolean;
votingOpen: boolean; votingOpen: boolean;
hookText: string; hookText: string;
result?: MatchResult | null; result?: MatchResult | null;