토너먼트 대진 추가

This commit is contained in:
jwkim 2026-06-29 09:11:58 +09:00
parent 59db1c4e9a
commit 6815540ae6
6 changed files with 161 additions and 50 deletions

View File

@ -23,6 +23,17 @@ from ..teams_data import code_for_tla
log = logging.getLogger("triplepick.schedule") log = logging.getLogger("triplepick.schedule")
KST = timezone(timedelta(hours=9)) 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" 형태 파싱 # "13:00 UTC-6" / "20:00 UTC+2" 형태 파싱
_TIME_RE = re.compile(r"(\d{1,2}):(\d{2})\s*UTC\s*([+-]\d{1,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]: 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: if not settings.football_data_token:
log.warning("schedule(all): football-data 토큰 미설정 — 수집 생략") log.warning("schedule(all): football-data 토큰 미설정 — 수집 생략")
@ -117,13 +131,19 @@ async def _fetch_all_groups_football_data() -> list[dict]:
out: list[dict] = [] out: list[dict] = []
for m in data.get("matches", []): for m in data.get("matches", []):
if m.get("stage") != "GROUP_STAGE": stage = m.get("stage")
continue if stage == "GROUP_STAGE":
g = (m.get("group") or "").replace("GROUP_", "") # 'A'..'L' 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") ht = (m.get("homeTeam") or {}).get("tla")
at = (m.get("awayTeam") or {}).get("tla") at = (m.get("awayTeam") or {}).get("tla")
if not ht or not at: if not ht or not at:
continue continue # 대진 미확정(TBD) — 확정 후 다음 동기화에서 등장
utc = m.get("utcDate") utc = m.get("utcDate")
if not utc: if not utc:
continue continue
@ -135,6 +155,7 @@ async def _fetch_all_groups_football_data() -> list[dict]:
"teamA": code_for_tla(ht), "teamA": code_for_tla(ht),
"teamB": code_for_tla(at), "teamB": code_for_tla(at),
"group": g, "group": g,
"roundLabel": round_label,
"kickoffKst": kickoff, "kickoffKst": kickoff,
"venue": m.get("venue") or "", "venue": m.get("venue") or "",
} }
@ -232,7 +253,12 @@ async def _results_football_data() -> list[dict]:
ft = ((m.get("score") or {}).get("fullTime") or {}) ft = ((m.get("score") or {}).get("fullTime") or {})
if ft.get("home") is None or ft.get("away") is None: if ft.get("home") is None or ft.get("away") is None:
continue 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 return out

View File

@ -21,24 +21,38 @@ from ..teams_data import team_info
log = logging.getLogger("triplepick.schedule") log = logging.getLogger("triplepick.schedule")
KST = timezone(timedelta(hours=9)) 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: async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
if not records: if not records:
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: dict[tuple, Match] = {} by_pair: dict[tuple, Match] = {}
for m in existing: for m in existing:
by_pair[(m.team_a_code, m.team_b_code)] = m p = _stage_prefix(m.group, m.round_label)
by_pair[(m.team_b_code, m.team_a_code)] = m 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 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) 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"]) 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 is not None:
if m.result_outcome is not None: if m.result_outcome is not None:
skipped += 1 skipped += 1
@ -52,10 +66,10 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
else: else:
ta, tb = team_info(a), team_info(b) ta, tb = team_info(a), team_info(b)
kst_date = kickoff.astimezone(KST).strftime("%Y%m%d") 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( new = Match(
match_id=match_id, match_id=match_id,
round_label="", round_label=round_label,
group=group, 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"],
@ -70,8 +84,8 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
) )
db.add(new) 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[(prefix, a, b)] = new
by_pair[(b, a)] = new by_pair[(prefix, b, a)] = new
inserted += 1 inserted += 1
await db.commit() await db.commit()

View File

@ -202,10 +202,23 @@ async def settle_matches() -> None:
if due or recheck_ids: if due or recheck_ids:
results = await fetch_results() results = await fetch_results()
# 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭. # 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭.
# 라운드(round_label)를 키에 포함해 같은 두 팀의 조별리그·토너먼트 경기를 구분.
# round_label 없는 소스(openfootball) 대비 라운드 무시 키도 함께 등록(폴백).
by_pair: dict[tuple, tuple[int, int]] = {} by_pair: dict[tuple, tuple[int, int]] = {}
for r in results: for r in results:
by_pair[(r["teamA"], r["teamB"])] = (r["scoreA"], r["scoreB"]) rl = r.get("roundLabel", "")
by_pair[(r["teamB"], r["teamA"])] = (r["scoreB"], r["scoreA"]) # 뒤집어 저장 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 settled = corrected = 0
async with SessionLocal() as db: async with SessionLocal() as db:
# 1) 신규 정산 — 미정산 경기에 FINISHED 스코어 반영 # 1) 신규 정산 — 미정산 경기에 FINISHED 스코어 반영
@ -213,7 +226,7 @@ async def settle_matches() -> None:
m = await db.get(Match, mid) m = await db.get(Match, mid)
if m is None or m.result_outcome is not None: if m is None or m.result_outcome is not None:
continue continue
sc = by_pair.get((m.team_a_code, m.team_b_code)) sc = _lookup(m)
if not sc: if not sc:
continue continue
await apply_result(db, m, sc[0], sc[1]) # 우리 팀A/팀B 기준으로 채점+집계+commit 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) m = await db.get(Match, mid)
if m is None or m.result_outcome is None: if m is None or m.result_outcome is None:
continue continue
sc = by_pair.get((m.team_a_code, m.team_b_code)) sc = _lookup(m)
if not sc: if not sc:
continue # 소스에 아직 없으면 기존값 유지 continue # 소스에 아직 없으면 기존값 유지
if (m.result_score_a, m.result_score_b) == (sc[0], sc[1]): if (m.result_score_a, m.result_score_b) == (sc[0], sc[1]):

View File

@ -1,6 +1,6 @@
import type { Match } from "@/lib/types"; import type { Match } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format"; 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 BallIcon from "./BallIcon";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
import WaveStrip from "./WaveStrip"; import WaveStrip from "./WaveStrip";
@ -37,7 +37,8 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
{teamShort(right, lang)} {teamShort(right, lang)}
</div> </div>
<div className="mt-1 text-[15px] font-bold text-[var(--green)]"> <div className="mt-1 text-[15px] font-bold text-[var(--green)]">
{kickoffDisplay(match.kickoffKst, lang)} · {t.group(group)} {kickoffDisplay(match.kickoffKst, lang)} ·{" "}
{group ? t.group(group) : tRound(match.roundLabel, lang)}
</div> </div>
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div> <div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
</div> </div>

View File

@ -22,7 +22,11 @@ const PHASE_CLS: Record<MatchPhase, string> = {
finished: "border-white/15 text-white/55", 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({ export default function ScheduleBoard({
matches, matches,
@ -32,21 +36,28 @@ export default function ScheduleBoard({
lang?: Lang; lang?: Lang;
}) { }) {
const t = dict(lang); const t = dict(lang);
const [tab, setTab] = useState<Tab>(() => const [tab, setTab] = useState<Tab>(() => {
sessionStorage.getItem("tp.sched.tab") === "group" ? "group" : "date", const saved = sessionStorage.getItem("tp.sched.tab");
); return saved === "group" || saved === "tournament" ? saved : "date";
});
// 날짜·조 목록 (정렬) // 날짜 목록 (정렬)
const dates = useMemo( const dates = useMemo(
() => Array.from(new Set(matches.map((m) => dateKey(m.kickoffKst)))).sort(), () => Array.from(new Set(matches.map((m) => dateKey(m.kickoffKst)))).sort(),
[matches], [matches],
); );
const groups = useMemo( // 조별리그 탭 칩: 조 문자(A~L)
() => Array.from(new Set(matches.map((m) => m.group))).sort(), const groups = useMemo(() => {
[matches], 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). // 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X).
const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []); const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
const [selDate, setSelDate] = useState(() => { const [selDate, setSelDate] = useState(() => {
@ -56,9 +67,14 @@ export default function ScheduleBoard({
}); });
const [selGroup, setSelGroup] = useState(() => { const [selGroup, setSelGroup] = useState(() => {
const saved = sessionStorage.getItem("tp.sched.group"); 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]) ?? ""; 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(() => { useEffect(() => {
@ -67,22 +83,35 @@ export default function ScheduleBoard({
useEffect(() => { useEffect(() => {
if (selGroup) sessionStorage.setItem("tp.sched.group", selGroup); if (selGroup) sessionStorage.setItem("tp.sched.group", selGroup);
}, [selGroup]); }, [selGroup]);
useEffect(() => {
if (selRound) sessionStorage.setItem("tp.sched.round", selRound);
}, [selRound]);
useEffect(() => { useEffect(() => {
sessionStorage.setItem("tp.sched.tab", tab); sessionStorage.setItem("tp.sched.tab", tab);
}, [tab]); }, [tab]);
const shown = useMemo(() => { const shown = useMemo(() => {
const list = let list: Match[];
tab === "date" if (tab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate);
? matches.filter((m) => dateKey(m.kickoffKst) === selDate) else if (tab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup);
: matches.filter((m) => m.group === selGroup); else list = matches.filter((m) => m.roundLabel === selRound); // 토너먼트
return [...list].sort( return [...list].sort(
(a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(), (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) => 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 ( return (
<section className="mt-6"> <section className="mt-6">
@ -91,9 +120,9 @@ export default function ScheduleBoard({
<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>
{/* 탭: 날짜별 / 조별리그 */} {/* 탭: 날짜별 / 조별리그 / 토너먼트 */}
<div className="mb-3 flex gap-5 border-b border-[var(--line-d)]"> <div className="mb-3 flex gap-5 border-b border-[var(--line-d)]">
{(["date", "group"] as Tab[]).map((k) => ( {(["date", "group", "tournament"] as Tab[]).map((k) => (
<button <button
key={k} key={k}
onClick={() => setTab(k)} onClick={() => setTab(k)}
@ -108,7 +137,7 @@ export default function ScheduleBoard({
))} ))}
</div> </div>
{/* 칩: 날짜 또는 조 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */} {/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */}
<ChipScroller resetKey={tab}> <ChipScroller resetKey={tab}>
{tab === "date" {tab === "date"
? dates.map((d) => ( ? dates.map((d) => (
@ -116,10 +145,16 @@ export default function ScheduleBoard({
{shortDate(d, lang)} {shortDate(d, lang)}
</Chip> </Chip>
)) ))
: groups.map((g) => ( : tab === "group"
? groups.map((g) => (
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}> <Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
{lang === "en" ? `Group ${g}` : `${g}조`} {lang === "en" ? `Group ${g}` : `${g}조`}
</Chip> </Chip>
))
: rounds.map((r) => (
<Chip key={r} active={r === selRound} onClick={() => setSelRound(r)}>
{tRound(r, lang)}
</Chip>
))} ))}
</ChipScroller> </ChipScroller>
@ -127,7 +162,13 @@ export default function ScheduleBoard({
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{shown.length === 0 && ( {shown.length === 0 && (
<p className="py-6 text-center text-[13px] text-white/45"> <p className="py-6 text-center text-[13px] text-white/45">
{lang === "en" ? "No matches" : "경기가 없습니다"} {tab === "tournament"
? lang === "en"
? "Bracket is set after the group stage."
: "토너먼트 대진은 조별리그 종료 후 확정됩니다"
: lang === "en"
? "No matches"
: "경기가 없습니다"}
</p> </p>
)} )}
{shown.map((m) => ( {shown.map((m) => (
@ -265,11 +306,16 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<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>
{match.roundLabel ? <> · {tRound(match.roundLabel, lang)}</> : null} {/* 조별리그 seed 의 부가 라벨(예: 개막전)만 여기에. 녹아웃 라운드는 우측에 표시. */}
{match.group && match.roundLabel ? <> · {tRound(match.roundLabel, lang)}</> : null}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-white/45"> <span className="text-[11px] font-bold text-white/45">
{lang === "en" ? `Group ${match.group}` : `${match.group}조`} {match.group
? lang === "en"
? `Group ${match.group}`
: `${match.group}조`
: tRound(match.roundLabel, lang)}
</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]}

View File

@ -31,9 +31,20 @@ export function monthEn(m: number): string {
return MONTHS_EN[m - 1]; return MONTHS_EN[m - 1];
} }
// 녹아웃 라운드 라벨(한글) → 영문
const ROUND_EN: Record<string, string> = {
"32강": "Round of 32",
"16강": "Round of 16",
"8강": "Quarter-final",
"4강": "Semi-final",
"3·4위전": "3rd-place",
"결승": "Final",
};
// roundLabel 안의 한국어 토막 번역 // roundLabel 안의 한국어 토막 번역
export function roundLabel(label: string, lang: Lang): string { export function roundLabel(label: string, lang: Lang): string {
if (lang === "ko") return label; if (lang === "ko") return label;
if (ROUND_EN[label]) return ROUND_EN[label];
return label.replace("개막전", "Opener"); return label.replace("개막전", "Opener");
} }