diff --git a/backend/app/services/schedule_fetch.py b/backend/app/services/schedule_fetch.py index 8a1b5a5..443c9ca 100644 --- a/backend/app/services/schedule_fetch.py +++ b/backend/app/services/schedule_fetch.py @@ -23,6 +23,17 @@ from ..teams_data import code_for_tla log = logging.getLogger("triplepick.schedule") KST = timezone(timedelta(hours=9)) + +# football-data stage → 녹아웃 라운드 라벨(한글). 조별리그는 group 으로 따로 처리. +# 2026 포맷(48개국)은 32강(LAST_32)부터 토너먼트가 시작된다. +KO_STAGE_LABEL = { + "LAST_32": "32강", + "LAST_16": "16강", + "QUARTER_FINALS": "8강", + "SEMI_FINALS": "4강", + "THIRD_PLACE": "3·4위전", + "FINAL": "결승", +} # "13:00 UTC-6" / "20:00 UTC+2" 형태 파싱 _TIME_RE = re.compile(r"(\d{1,2}):(\d{2})\s*UTC\s*([+-]\d{1,2})") @@ -97,9 +108,12 @@ async def _fetch_football_data() -> list[dict]: async def _fetch_all_groups_football_data() -> list[dict]: - """football-data 에서 전체 조별리그(12개조 72경기) 수집. + """football-data 에서 전체 조별리그 + 녹아웃 토너먼트 일정 수집. - 반환: [{teamA, teamB, group('A'..'L'), kickoffKst, venue}] (tla→코드) + 반환: [{teamA, teamB, group('A'..'L' 또는 ''), roundLabel('' 또는 '32강'..), kickoffKst, venue}] + 조별리그는 group 으로, 32강 이후 토너먼트는 roundLabel 로 구분한다(tla→코드). + 녹아웃 경기는 대진이 확정되기 전엔 팀이 TBD 라 소스가 tla 를 주지 않아 자동 제외되고, + 조별리그 종료로 대진이 확정되면 다음 동기화에서 자동 등장한다. """ if not settings.football_data_token: log.warning("schedule(all): football-data 토큰 미설정 — 수집 생략") @@ -117,13 +131,19 @@ async def _fetch_all_groups_football_data() -> list[dict]: 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' + stage = m.get("stage") + if stage == "GROUP_STAGE": + g = (m.get("group") or "").replace("GROUP_", "") # 'A'..'L' + round_label = "" + elif stage in KO_STAGE_LABEL: + g = "" # 녹아웃은 조 없음 — roundLabel 로 구분 + round_label = KO_STAGE_LABEL[stage] + else: + continue # 예선/플레이오프 등은 제외 ht = (m.get("homeTeam") or {}).get("tla") at = (m.get("awayTeam") or {}).get("tla") if not ht or not at: - continue + continue # 대진 미확정(TBD) — 확정 후 다음 동기화에서 등장 utc = m.get("utcDate") if not utc: continue @@ -135,6 +155,7 @@ async def _fetch_all_groups_football_data() -> list[dict]: "teamA": code_for_tla(ht), "teamB": code_for_tla(at), "group": g, + "roundLabel": round_label, "kickoffKst": kickoff, "venue": m.get("venue") or "", } @@ -232,7 +253,12 @@ async def _results_football_data() -> list[dict]: ft = ((m.get("score") or {}).get("fullTime") or {}) if ft.get("home") is None or ft.get("away") is None: continue - out.append({"teamA": a, "teamB": b, "scoreA": int(ft["home"]), "scoreB": int(ft["away"])}) + # 라운드 표기 — 같은 두 팀이 조별리그·토너먼트에서 만나도 정산을 구분(없으면 ""). + round_label = KO_STAGE_LABEL.get(m.get("stage") or "", "") + out.append({ + "teamA": a, "teamB": b, "scoreA": int(ft["home"]), "scoreB": int(ft["away"]), + "roundLabel": round_label, + }) return out diff --git a/backend/app/services/schedule_sync.py b/backend/app/services/schedule_sync.py index d669166..2681b11 100644 --- a/backend/app/services/schedule_sync.py +++ b/backend/app/services/schedule_sync.py @@ -21,24 +21,38 @@ from ..teams_data import team_info log = logging.getLogger("triplepick.schedule") KST = timezone(timedelta(hours=9)) +# 녹아웃 라운드 라벨 → match_id 접두어(조가 없는 토너먼트 경기 식별용) +KO_PREFIX = {"32강": "R32", "16강": "R16", "8강": "QF", "4강": "SF", "3·4위전": "P3", "결승": "F"} + + +def _stage_prefix(group: str, round_label: str) -> str: + """경기 식별 접두어 — 조별리그는 조 문자, 녹아웃은 라운드 코드. + 같은 두 팀이 조별리그와 결승에서 다시 만나도 서로 다른 경기로 구분된다.""" + return group or KO_PREFIX.get(round_label, "KO") + async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict: if not records: return {"updated": 0, "inserted": 0, "skipped": 0} existing = (await db.execute(select(Match))).scalars().all() - # 순서 무관 매칭 — 소스의 홈/원정 순서가 우리와 달라도 기존 경기를 찾음(중복 방지) + # 순서 무관 매칭 — 소스의 홈/원정 순서가 우리와 달라도 기존 경기를 찾음(중복 방지). + # 단계(조/라운드)도 키에 포함 — 같은 두 팀의 조별리그 경기와 토너먼트 경기를 구분. 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 + p = _stage_prefix(m.group, m.round_label) + by_pair[(p, m.team_a_code, m.team_b_code)] = m + by_pair[(p, 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) + round_label = rec.get("roundLabel", "") + # 조별리그는 조 문자, 녹아웃은 조 없음(""). roundLabel 이 있으면 토너먼트 경기. + group = "" if round_label else rec.get("group", settings.featured_group) + prefix = _stage_prefix(group, round_label) kickoff = parse_kickoff(rec["kickoffKst"]) - m = by_pair.get((a, b)) + m = by_pair.get((prefix, a, b)) if m is not None: if m.result_outcome is not None: skipped += 1 @@ -52,10 +66,10 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict: else: 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}" + match_id = f"{prefix}_{a}_{b}_{kst_date}" new = Match( match_id=match_id, - round_label="", + round_label=round_label, group=group, team_a_name=ta["name"], team_a_short=ta["shortName"], team_a_code=ta["code"], team_a_flag=ta["flag"], @@ -70,8 +84,8 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict: ) 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 + by_pair[(prefix, a, b)] = new + by_pair[(prefix, b, a)] = new inserted += 1 await db.commit() diff --git a/backend/app/worker.py b/backend/app/worker.py index ac25d6f..266aee2 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -202,10 +202,23 @@ async def settle_matches() -> None: if due or recheck_ids: results = await fetch_results() # 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭. + # 라운드(round_label)를 키에 포함해 같은 두 팀의 조별리그·토너먼트 경기를 구분. + # round_label 없는 소스(openfootball) 대비 라운드 무시 키도 함께 등록(폴백). by_pair: dict[tuple, tuple[int, int]] = {} for r in results: - by_pair[(r["teamA"], r["teamB"])] = (r["scoreA"], r["scoreB"]) - by_pair[(r["teamB"], r["teamA"])] = (r["scoreB"], r["scoreA"]) # 뒤집어 저장 + rl = r.get("roundLabel", "") + a2, b2, sa, sb = r["teamA"], r["teamB"], r["scoreA"], r["scoreB"] + for key in ((rl, a2, b2), (a2, b2)): + by_pair[key] = (sa, sb) + for key in ((rl, b2, a2), (b2, a2)): # 뒤집어 저장 + by_pair[key] = (sb, sa) + + def _lookup(m: Match) -> tuple[int, int] | None: + # 라운드까지 일치하는 결과 우선, 없으면 팀쌍만으로 폴백. + return by_pair.get((m.round_label, m.team_a_code, m.team_b_code)) or by_pair.get( + (m.team_a_code, m.team_b_code) + ) + settled = corrected = 0 async with SessionLocal() as db: # 1) 신규 정산 — 미정산 경기에 FINISHED 스코어 반영 @@ -213,7 +226,7 @@ async def settle_matches() -> None: m = await db.get(Match, mid) if m is None or m.result_outcome is not None: continue - sc = by_pair.get((m.team_a_code, m.team_b_code)) + sc = _lookup(m) if not sc: continue await apply_result(db, m, sc[0], sc[1]) # 우리 팀A/팀B 기준으로 채점+집계+commit @@ -224,7 +237,7 @@ async def settle_matches() -> None: m = await db.get(Match, mid) if m is None or m.result_outcome is None: continue - sc = by_pair.get((m.team_a_code, m.team_b_code)) + sc = _lookup(m) if not sc: continue # 소스에 아직 없으면 기존값 유지 if (m.result_score_a, m.result_score_b) == (sc[0], sc[1]): diff --git a/frontend/src/components/MatchupHUD.tsx b/frontend/src/components/MatchupHUD.tsx index 3fb4db8..9a866c8 100644 --- a/frontend/src/components/MatchupHUD.tsx +++ b/frontend/src/components/MatchupHUD.tsx @@ -1,6 +1,6 @@ import type { Match } from "@/lib/types"; import { kickoffDisplay } from "@/lib/format"; -import { type Lang, dict, teamShort } from "@/lib/i18n"; +import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n"; import BallIcon from "./BallIcon"; import TeamFlag from "./TeamFlag"; import WaveStrip from "./WaveStrip"; @@ -37,7 +37,8 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang? {teamShort(right, lang)}
- {kickoffDisplay(match.kickoffKst, lang)} · {t.group(group)} + {kickoffDisplay(match.kickoffKst, lang)} ·{" "} + {group ? t.group(group) : tRound(match.roundLabel, lang)}
{match.venue}
diff --git a/frontend/src/components/ScheduleBoard.tsx b/frontend/src/components/ScheduleBoard.tsx index d190510..45bfb27 100644 --- a/frontend/src/components/ScheduleBoard.tsx +++ b/frontend/src/components/ScheduleBoard.tsx @@ -22,7 +22,11 @@ const PHASE_CLS: Record = { finished: "border-white/15 text-white/55", }; -type Tab = "date" | "group"; +// 토너먼트 탭 라운드 칩 정렬(대진 순서) +const KO_ROUND_ORDER = ["32강", "16강", "8강", "4강", "3·4위전", "결승"]; +const isGroupLetter = (s: string) => /^[A-Z]$/.test(s); + +type Tab = "date" | "group" | "tournament"; export default function ScheduleBoard({ matches, @@ -32,21 +36,28 @@ export default function ScheduleBoard({ lang?: Lang; }) { const t = dict(lang); - const [tab, setTab] = useState(() => - sessionStorage.getItem("tp.sched.tab") === "group" ? "group" : "date", - ); + const [tab, setTab] = useState(() => { + const saved = sessionStorage.getItem("tp.sched.tab"); + return saved === "group" || saved === "tournament" ? saved : "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~L) + const groups = useMemo(() => { + const set = new Set(matches.map((m) => m.group).filter(Boolean)); + return [...set].filter(isGroupLetter).sort(); + }, [matches]); + // 토너먼트 탭 칩: 등장한 라운드(32강~결승), 대진 순서 + const rounds = useMemo(() => { + const set = new Set(matches.map((m) => m.roundLabel).filter(Boolean)); + return KO_ROUND_ORDER.filter((r) => set.has(r)); + }, [matches]); - // 기본 선택: 오늘(없으면 첫 날짜) / A조. + // 기본 선택: 오늘(없으면 첫 날짜) / A조 / 첫 라운드. // 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X). const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []); const [selDate, setSelDate] = useState(() => { @@ -56,9 +67,14 @@ export default function ScheduleBoard({ }); const [selGroup, setSelGroup] = useState(() => { const saved = sessionStorage.getItem("tp.sched.group"); - if (saved && groups.includes(saved)) return saved; + if (saved && isGroupLetter(saved)) return saved; return (groups.includes("A") ? "A" : groups[0]) ?? ""; }); + const [selRound, setSelRound] = useState(() => { + const saved = sessionStorage.getItem("tp.sched.round"); + if (saved && KO_ROUND_ORDER.includes(saved)) return saved; + return rounds[0] ?? ""; + }); // 선택 변경 시 보관(다음 진입/뒤로가기에서 복원) useEffect(() => { @@ -67,22 +83,35 @@ export default function ScheduleBoard({ useEffect(() => { if (selGroup) sessionStorage.setItem("tp.sched.group", selGroup); }, [selGroup]); + useEffect(() => { + if (selRound) sessionStorage.setItem("tp.sched.round", selRound); + }, [selRound]); useEffect(() => { sessionStorage.setItem("tp.sched.tab", tab); }, [tab]); const shown = useMemo(() => { - const list = - tab === "date" - ? matches.filter((m) => dateKey(m.kickoffKst) === selDate) - : matches.filter((m) => m.group === selGroup); + let list: Match[]; + if (tab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate); + else if (tab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup); + else list = matches.filter((m) => m.roundLabel === selRound); // 토너먼트 return [...list].sort( (a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(), ); - }, [matches, tab, selDate, selGroup]); + }, [matches, tab, selDate, selGroup, selRound]); const tabLabel = (k: Tab) => - k === "date" ? (lang === "en" ? "By date" : "날짜별") : lang === "en" ? "Groups" : "조별리그"; + k === "date" + ? lang === "en" + ? "By date" + : "날짜별" + : k === "group" + ? lang === "en" + ? "Groups" + : "조별리그" + : lang === "en" + ? "Tournament" + : "토너먼트"; return (
@@ -91,9 +120,9 @@ export default function ScheduleBoard({ {t.schedGuide} - {/* 탭: 날짜별 / 조별리그 */} + {/* 탭: 날짜별 / 조별리그 / 토너먼트 */}
- {(["date", "group"] as Tab[]).map((k) => ( + {(["date", "group", "tournament"] as Tab[]).map((k) => (
- {/* 칩: 날짜 또는 조 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */} + {/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */} {tab === "date" ? dates.map((d) => ( @@ -116,18 +145,30 @@ export default function ScheduleBoard({ {shortDate(d, lang)} )) - : groups.map((g) => ( - setSelGroup(g)}> - {lang === "en" ? `Group ${g}` : `${g}조`} - - ))} + : tab === "group" + ? groups.map((g) => ( + setSelGroup(g)}> + {lang === "en" ? `Group ${g}` : `${g}조`} + + )) + : rounds.map((r) => ( + setSelRound(r)}> + {tRound(r, lang)} + + ))} {/* 경기 카드 */}
{shown.length === 0 && (

- {lang === "en" ? "No matches" : "경기가 없습니다"} + {tab === "tournament" + ? lang === "en" + ? "Bracket is set after the group stage." + : "토너먼트 대진은 조별리그 종료 후 확정됩니다" + : lang === "en" + ? "No matches" + : "경기가 없습니다"}

)} {shown.map((m) => ( @@ -265,11 +306,16 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
{timeOnly(match.kickoffKst)} KST - {match.roundLabel ? <> · {tRound(match.roundLabel, lang)} : null} + {/* 조별리그 seed 의 부가 라벨(예: 개막전)만 여기에. 녹아웃 라운드는 우측에 표시. */} + {match.group && match.roundLabel ? <> · {tRound(match.roundLabel, lang)} : null} - {lang === "en" ? `Group ${match.group}` : `${match.group}조`} + {match.group + ? lang === "en" + ? `Group ${match.group}` + : `${match.group}조` + : tRound(match.roundLabel, lang)} {t.phase[phase]} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 0f1f32b..ee24eeb 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -31,9 +31,20 @@ export function monthEn(m: number): string { return MONTHS_EN[m - 1]; } +// 녹아웃 라운드 라벨(한글) → 영문 +const ROUND_EN: Record = { + "32강": "Round of 32", + "16강": "Round of 16", + "8강": "Quarter-final", + "4강": "Semi-final", + "3·4위전": "3rd-place", + "결승": "Final", +}; + // roundLabel 안의 한국어 토막 번역 export function roundLabel(label: string, lang: Lang): string { if (lang === "ko") return label; + if (ROUND_EN[label]) return ROUND_EN[label]; return label.replace("개막전", "Opener"); }