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
parent
c9244bc42d
commit
deb1332440
|
|
@ -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 사용.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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": ""}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 : ["-"];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue