import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { timeOnly, dateKey } from "@/lib/format"; import { getLive, type LiveData } from "@/lib/api"; import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n"; import type { League, Match, MatchPhase, ModelName, Outcome } from "@/lib/types"; import TeamFlag from "./TeamFlag"; import StandingsTable from "./StandingsTable"; // 칩용 짧은 날짜: "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 = { open: "border-[#94FBE0] text-[#94FBE0]", scheduled: "border-white/20 text-white/60", locked: "border-white/20 text-white/60", live: "border-[#FF5B5B] text-[#FF5B5B]", finished: "border-white/25 text-white/70", cancelled: "border-white/10 text-white/40", }; // 토너먼트 탭 라운드 칩 정렬(대진 순서) const KO_ROUND_ORDER = ["32강", "16강", "8강", "4강", "3·4위전", "결승"]; const isGroupLetter = (s: string) => /^[A-Z]$/.test(s); type Tab = "date" | "group" | "tournament" | "team" | "rank"; export default function ScheduleBoard({ matches, lang = "ko", league = "wc", }: { matches: Match[]; lang?: Lang; league?: League; }) { const t = dict(lang); // 야구(KBO/MLB)는 조/토너먼트 대신 날짜별/팀별 탭 제공 const isBaseball = league !== "wc"; // 날짜 목록 (정렬) const dates = useMemo( () => Array.from(new Set(matches.map((m) => dateKey(m.kickoffKst)))).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]); // 현재 진행 라운드: 아직 종료되지 않은 경기가 있는 가장 이른 라운드. // (8강 진행 중이면 8강, 8강이 다 끝나 4강 대진이 뜨면 4강.) 전부 종료면 가장 진행된 라운드. const currentRound = useMemo(() => { for (const r of rounds) { if (matches.some((m) => m.roundLabel === r && !m.result)) return r; } return rounds[rounds.length - 1] ?? ""; }, [matches, rounds]); // 시작 탭: 토너먼트가 시작됐으면 '토너먼트', 아니면 '날짜별'. // 단, 직전 세션 선택이 있으면 우선(상세 진입 후 뒤로가기 복원). const [tab, setTabRaw] = useState(() => { const saved = sessionStorage.getItem("tp.sched.tab"); if ( saved === "date" || saved === "group" || saved === "tournament" || saved === "team" || saved === "rank" ) return saved; if (isBaseball) return "date"; return rounds.length > 0 ? "tournament" : "date"; }); // 리그에 없는 탭이 저장돼 있으면 날짜별로 강등 (야구↔축구 전환 시) const effTab: Tab = isBaseball ? (tab === "team" || tab === "rank" ? tab : "date") : (tab === "team" || tab === "rank" ? "date" : tab); const setTab = setTabRaw; // 팀별 탭: 등장 팀 목록 (짧은 이름 가나다/알파벳 순) const teams = useMemo(() => { if (!isBaseball) return []; const byCode = new Map(); for (const m of matches) { byCode.set(m.teamA.code, m.teamA); byCode.set(m.teamB.code, m.teamB); } return [...byCode.values()].sort((a, b) => teamShort(a, lang).localeCompare(teamShort(b, lang), lang === "en" ? "en" : "ko"), ); }, [matches, isBaseball, lang]); // 기본 선택: 오늘(경기 없으면 다음 경기일, 시즌 종료면 마지막 날짜) / A조 / 현재 진행 라운드. // 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X). // 사이트 전체가 KST 기준이라 "오늘"도 클라이언트 시간대와 무관하게 KST 로 계산. const todayKey = useMemo( () => new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10), [], ); const defaultDate = (ds: string[], today: string) => (ds.includes(today) ? today : ds.find((d) => d > today) ?? ds[ds.length - 1]) ?? ""; const [selDate, setSelDate] = useState(() => { const saved = sessionStorage.getItem("tp.sched.date"); if (saved && dates.includes(saved)) return saved; return defaultDate(dates, todayKey); }); const [selGroup, setSelGroup] = useState(() => { const saved = sessionStorage.getItem("tp.sched.group"); 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 && rounds.includes(saved)) return saved; return currentRound; }); const [selTeam, setSelTeam] = useState( () => sessionStorage.getItem("tp.sched.team") ?? "", ); // 선택 변경 시 보관(다음 진입/뒤로가기에서 복원) useEffect(() => { if (selDate) sessionStorage.setItem("tp.sched.date", selDate); }, [selDate]); 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]); useEffect(() => { if (selTeam) sessionStorage.setItem("tp.sched.team", selTeam); }, [selTeam]); useEffect(() => { if (dates.length && !dates.includes(selDate)) { // 목록이 비동기 로드될 때도 세션 복원이 동작하게 saved 를 우선 확인 const saved = sessionStorage.getItem("tp.sched.date"); setSelDate( saved && dates.includes(saved) ? saved : defaultDate(dates, todayKey), ); } }, [dates, selDate, todayKey]); // 리그 전환 시 목록에 없는 팀이면 첫 팀으로 useEffect(() => { if (teams.length && !teams.some((tm) => tm.code === selTeam)) { setSelTeam(teams[0].code); } }, [teams, selTeam]); const shown = useMemo(() => { let list: Match[]; if (effTab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate); else if (effTab === "team") list = matches.filter((m) => m.teamA.code === selTeam || m.teamB.code === selTeam); else if (effTab === "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, effTab, selDate, selGroup, selRound, selTeam]); const tabLabel = (k: Tab) => k === "date" ? lang === "en" ? "By date" : "날짜별" : k === "team" ? lang === "en" ? "By team" : "팀별" : k === "rank" ? lang === "en" ? "Standings" : "순위" : k === "group" ? lang === "en" ? "Groups" : "조별리그" : lang === "en" ? "Tournament" : "토너먼트"; const leagueTabs: Tab[] = isBaseball ? ["date", "team", "rank"] : ["date", "group", "tournament"]; return (

{t.schedTitle}

{t.schedGuide}
{/* 탭: 축구 = 날짜별/조별리그/토너먼트 · 야구 = 날짜별/팀별 */}
{leagueTabs.map((k) => ( ))}
{/* 순위 탭: 칩/카드 대신 순위표 */} {effTab === "rank" && } {/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */} {effTab !== "rank" && ( {effTab === "date" ? dates.map((d) => ( setSelDate(d)}> {shortDate(d, lang)} )) : effTab === "team" ? teams.map((tm) => ( setSelTeam(tm.code)}> {teamShort(tm, lang)} )) : effTab === "group" ? groups.map((g) => ( setSelGroup(g)}> {lang === "en" ? `Group ${g}` : `${g}조`} )) : rounds.map((r) => ( setSelRound(r)}> {tRound(r, lang)} ))} )} {/* 경기 카드 */} {effTab !== "rank" && (
{shown.length === 0 && (

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

)} {shown.map((m) => ( ))}
)}
); } // 단일 줄 가로 스크롤 + 양 끝 화살표. 끝에 닿으면 해당 방향 화살표를 숨긴다. // resetKey 가 바뀌면(탭 전환 등) 맨 앞으로 되감고 재측정한다. function ChipScroller({ children, resetKey, centerKey, }: { children: React.ReactNode; resetKey?: string | number; centerKey?: string; // 현재 선택값 — 바뀔 때마다 선택 칩을 가운데로 (비동기 로드·칩 클릭 포함) }) { const ref = useRef(null); const [atStart, setAtStart] = useState(true); const [atEnd, setAtEnd] = useState(true); const measure = () => { const el = ref.current; if (!el) return; const max = el.scrollWidth - el.clientWidth; setAtStart(el.scrollLeft <= 1); setAtEnd(el.scrollLeft >= max - 1); }; // 탭/목록/선택 변경 시: 선택된 칩을 가운데로 + 재측정 (레이아웃 확정 후) // 선택 칩이 없으면 맨 앞으로 되감는다. useLayoutEffect(() => { const el = ref.current; if (el) { const active = el.querySelector('[data-active="true"]'); el.scrollLeft = active ? active.offsetLeft - (el.clientWidth - active.clientWidth) / 2 : 0; } measure(); }, [resetKey, centerKey]); useEffect(() => { measure(); const el = ref.current; if (!el) return; el.addEventListener("scroll", measure, { passive: true }); window.addEventListener("resize", measure); return () => { el.removeEventListener("scroll", measure); window.removeEventListener("resize", measure); }; }, []); const nudge = (dir: 1 | -1) => ref.current?.scrollBy({ left: dir * 220, behavior: "smooth" }); return (
{children}
{!atStart && (
)} {!atEnd && (
)}
); } function Chip({ active, onClick, children, }: { active: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } // MLS 최근 5경기 폼 — "WWLDW" 를 W/D/L 색으로 표시 export function FormBadges({ form }: { form?: string | null }) { if (!form) return ?; return ( {form.split("").map((c, i) => ( {c} ))} ); } // 팀명 옆 순위 배지 (야구·MLS — 순위 캐시 없으면 렌더 안 함) function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) { if (!rank) return null; return ( {lang === "en" ? `#${rank}` : `${rank}위`} ); } // AI 픽 아이콘 (일정 카드용 — Arena 와 동일 공식 로고) const MODEL_ICON: Record = { GPT: "/icons/gpt.png", Claude: "/icons/claude.jpg", Gemini: "/icons/gemini.jpeg", }; function AiPickIcons({ models }: { models: ModelName[] }) { return ( <> {models.map((m) => ( {m} ))} ); } function MatchCard({ match, lang, showDate = false, }: { match: Match; lang: Lang; showDate?: boolean; }) { const t = dict(lang); const phase = match.phase; const finished = !!match.result; const isBaseball = match.league === "kbo" || match.league === "mlb"; const isMls = match.league === "mls"; const isClub = isBaseball || isMls; // 클럽 리그 공통 UI (원정-홈 고정·로고·순위 뱃지) // 진행 중: 회차(야구)/경기 분(MLS)·스코어 표시 (서버 15초 캐시, 30초 폴링) const [live, setLive] = useState(null); useEffect(() => { if (phase !== "live" || !isClub) return; let alive = true; const load = () => getLive(match.matchId) .then((d) => { if (alive && d.available) setLive(d); }) .catch(() => {}); load(); const id = setInterval(load, 30_000); return () => { alive = false; clearInterval(id); }; }, [match.matchId, phase, isClub]); // 야구 "6회초" / "Top 6" · MLS 경기 분 "45'" const liveBadge = isMls ? live?.clock || null : live?.inn != null ? lang === "en" ? `${live.half === "B" ? "Bot" : "Top"} ${live.inn}` : `${live.inn}회${live.half === "B" ? "말" : "초"}` : null; // 킥오프 시각은 지났지만 소스가 아직 시작 전(state=pre)이라는 경기 — 지연 등 const notStarted = isMls && phase === "live" && live?.state === "pre"; // 월드컵: 한국을 항상 왼쪽에. MLS: 홈 팀을 왼쪽에(축구 관례). 데이터는 원본 A/B 유지, 화면 좌우만 교체. const flip = isMls || (!isClub && match.teamB.code === "KOR" && match.teamA.code !== "KOR"); const left = flip ? match.teamB : match.teamA; const right = flip ? match.teamA : match.teamB; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; const leftSt = flip ? match.extras?.standings?.b : match.extras?.standings?.a; const rightSt = flip ? match.extras?.standings?.a : match.extras?.standings?.b; const flagCls = isClub ? "h-7 w-7 shrink-0" : "h-6 w-9 shrink-0"; // AI 픽: 픽한 팀 아래에 모델 아이콘 배치 (무승부 픽은 가운데) const picksFor = (o: Outcome) => match.predictions.filter((p) => p.outcome === o).map((p) => p.model); const leftPicks = picksFor(flip ? "TEAM_B_WIN" : "TEAM_A_WIN"); const rightPicks = picksFor(flip ? "TEAM_A_WIN" : "TEAM_B_WIN"); const drawPicks = picksFor("DRAW"); const showAiRow = match.votable && phase !== "cancelled" && match.predictions.length > 0; // 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동 const inner = ( <>
{showDate && ( {shortDate(dateKey(match.kickoffKst), lang)} )} {timeOnly(match.kickoffKst)} KST {isClub && match.venue ? ( · {match.venue} ) : match.group && match.roundLabel ? ( <> · {tRound(match.roundLabel, lang)} ) : null} {!isClub && ( {match.group ? lang === "en" ? `Group ${match.group}` : `${match.group}조` : tRound(match.roundLabel, lang)} )} {notStarted ? lang === "en" ? "Not started" : "시작 전" : phase === "live" && liveBadge ? liveBadge : t.phase[phase]}
{teamShort(left, lang)} {isClub && }
{match.result ? ( {leftScore} : {rightScore} ) : phase === "live" && live?.score ? ( // 진행 중 실시간 스코어 — flip 시(MLS) 홈 먼저 {(flip ? live.score.home : live.score.away) ?? 0} : {(flip ? live.score.away : live.score.home) ?? 0} ) : ( VS )}
{isClub && } {teamShort(right, lang)}
{/* AI 픽: 각 모델 아이콘을 픽한 팀 아래에 표시 (무승부 픽은 가운데) */} {showAiRow && (
)} {/* 야구: 선발 매치업 (프리뷰 캐시 있을 때만) */} {isBaseball && !match.result && (match.extras?.starterA || match.extras?.starterB) && (
{lang === "en" ? "SP" : "선발"}{" "} {match.extras?.starterA?.name ?? "?"} vs {match.extras?.starterB?.name ?? "?"}
)} {/* MLS: 최근 5경기 폼 (프리뷰 캐시 있을 때만) — 홈(왼쪽) 먼저 */} {isMls && !match.result && (match.extras?.formA || match.extras?.formB) && (
{lang === "en" ? "Last 5" : "최근 5경기"}{" "} vs
)} {/* 투표 가능 조만: 참여수 + 이동 화살표 (AI 픽은 위의 아이콘 행이 담당, 취소 경기는 숨김) */} {match.votable && phase !== "cancelled" && (
{t.joined((match.crowd?.total ?? 0).toLocaleString())} →
)} ); const base = "block rounded-2xl border-2 bg-[#171b21] p-4"; // 취소 경기: 클릭 불가·저채도 표시 전용 if (phase === "cancelled") { return
{inner}
; } if (match.votable && !finished) { return ( {inner} ); } // 종료된 투표 경기는 결과 보기로, 비투표 경기는 표시 전용(클릭 시 상세=결과) if (match.votable) { return ( {inner} ); } return
{inner}
; } 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 entA = tally.a ? `${teamShort(match.teamA, lang)} ${tally.a}` : null; const entD = tally.d ? `${t.draw} ${tally.d}` : null; const entB = tally.b ? `${teamShort(match.teamB, lang)} ${tally.b}` : null; // MLS 는 홈 팀 먼저 (카드 좌우 표시와 동일 순서) const out = (match.league === "mls" ? [entB, entD, entA] : [entA, entD, entB]).filter( (x): x is string => !!x, ); return out.length ? out : ["-"]; }