토너먼트 대진 추가

develop
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")
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

View File

@ -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()

View File

@ -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]):

View File

@ -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)}
</div>
<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 className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
</div>

View File

@ -22,7 +22,11 @@ const PHASE_CLS: Record<MatchPhase, string> = {
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<Tab>(() =>
sessionStorage.getItem("tp.sched.tab") === "group" ? "group" : "date",
);
const [tab, setTab] = useState<Tab>(() => {
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 (
<section className="mt-6">
@ -91,9 +120,9 @@ export default function ScheduleBoard({
<span className="whitespace-nowrap text-[12px] text-white/55">{t.schedGuide}</span>
</div>
{/* 탭: 날짜별 / 조별리그 */}
{/* 탭: 날짜별 / 조별리그 / 토너먼트 */}
<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
key={k}
onClick={() => setTab(k)}
@ -108,7 +137,7 @@ export default function ScheduleBoard({
))}
</div>
{/* 칩: 날짜 또는 조 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */}
{/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */}
<ChipScroller resetKey={tab}>
{tab === "date"
? dates.map((d) => (
@ -116,18 +145,30 @@ export default function ScheduleBoard({
{shortDate(d, lang)}
</Chip>
))
: groups.map((g) => (
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
{lang === "en" ? `Group ${g}` : `${g}`}
</Chip>
))}
: tab === "group"
? groups.map((g) => (
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
{lang === "en" ? `Group ${g}` : `${g}`}
</Chip>
))
: rounds.map((r) => (
<Chip key={r} active={r === selRound} onClick={() => setSelRound(r)}>
{tRound(r, lang)}
</Chip>
))}
</ChipScroller>
{/* 경기 카드 */}
<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" : "경기가 없습니다"}
{tab === "tournament"
? lang === "en"
? "Bracket is set after the group stage."
: "토너먼트 대진은 조별리그 종료 후 확정됩니다"
: lang === "en"
? "No matches"
: "경기가 없습니다"}
</p>
)}
{shown.map((m) => (
@ -265,11 +306,16 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<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>
{match.roundLabel ? <> · {tRound(match.roundLabel, lang)}</> : null}
{/* 조별리그 seed 의 부가 라벨(예: 개막전)만 여기에. 녹아웃 라운드는 우측에 표시. */}
{match.group && 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}`}
{match.group
? lang === "en"
? `Group ${match.group}`
: `${match.group}`
: tRound(match.roundLabel, lang)}
</span>
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{t.phase[phase]}

View File

@ -31,9 +31,20 @@ export function monthEn(m: number): string {
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 안의 한국어 토막 번역
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");
}