토너먼트 대진 추가
parent
59db1c4e9a
commit
6815540ae6
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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]):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,18 +145,30 @@ export default function ScheduleBoard({
|
||||||
{shortDate(d, lang)}
|
{shortDate(d, lang)}
|
||||||
</Chip>
|
</Chip>
|
||||||
))
|
))
|
||||||
: groups.map((g) => (
|
: tab === "group"
|
||||||
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
|
? groups.map((g) => (
|
||||||
{lang === "en" ? `Group ${g}` : `${g}조`}
|
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
|
||||||
</Chip>
|
{lang === "en" ? `Group ${g}` : `${g}조`}
|
||||||
))}
|
</Chip>
|
||||||
|
))
|
||||||
|
: rounds.map((r) => (
|
||||||
|
<Chip key={r} active={r === selRound} onClick={() => setSelRound(r)}>
|
||||||
|
{tRound(r, lang)}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
</ChipScroller>
|
</ChipScroller>
|
||||||
|
|
||||||
{/* 경기 카드 */}
|
{/* 경기 카드 */}
|
||||||
<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]}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue