o2o-triple-pick/frontend/src/components/ScheduleBoard.tsx
jwkim 74de57fc1c feat(mls): MLS 리그 추가 — ESPN 비공식 API 연동
- 일정·결과·컨퍼런스 순위·프리뷰(폼/시즌전적/맞대결/머니라인) 수집
- 라이브 카드: 스코어·경기시간·득점/카드·선발 라인업(피치 뷰)·교체 현황
- 문자중계: ESPN commentary 규칙 기반 한글 변환 (미지원 템플릿은 팀명만 한글화)
- MLS 레코드는 야구와 키 구조가 같아 sync_baseball_schedule/settle 재사용
- AI 예측 데이터 블록에 MLS 전용 컨텍스트 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:47:13 +09:00

617 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } 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<MatchPhase, string> = {
open: "border-[#94FBE0] text-[#94FBE0]",
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]",
locked: "border-[var(--line-d)] text-white/45",
live: "border-[#FF5B5B] text-[#FF5B5B]",
finished: "border-white/15 text-white/55",
cancelled: "border-white/10 text-white/35",
};
// 토너먼트 탭 라운드 칩 정렬(대진 순서)
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<Tab>(() => {
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<string, Match["teamA"]>();
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).
const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
const [selDate, setSelDate] = useState(() => {
const saved = sessionStorage.getItem("tp.sched.date");
if (saved && dates.includes(saved)) return saved;
return (dates.includes(todayKey) ? todayKey : dates[0]) ?? "";
});
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)) {
setSelDate(dates.includes(todayKey) ? todayKey : dates[0]);
}
}, [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 (
<section className="mt-6">
<div className="mb-3 flex items-baseline gap-2.5">
<h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2>
<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)]">
{leagueTabs.map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={`-mb-px border-b-2 pb-2 text-[15px] font-bold transition ${
effTab === k
? "border-[var(--green)] text-white"
: "border-transparent text-white/45"
}`}
>
{tabLabel(k)}
</button>
))}
</div>
{/* 순위 탭: 칩/카드 대신 순위표 */}
{effTab === "rank" && <StandingsTable league={league} lang={lang} />}
{/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */}
{effTab !== "rank" && (
<ChipScroller resetKey={`${league}:${effTab}`}>
{effTab === "date"
? dates.map((d) => (
<Chip key={d} active={d === selDate} onClick={() => setSelDate(d)}>
{shortDate(d, lang)}
</Chip>
))
: effTab === "team"
? teams.map((tm) => (
<Chip key={tm.code} active={tm.code === selTeam} onClick={() => setSelTeam(tm.code)}>
<span className="flex items-center gap-1.5">
<TeamFlag team={tm} className="h-4 w-4 shrink-0" />
{teamShort(tm, lang)}
</span>
</Chip>
))
: effTab === "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>
)}
{/* 경기 카드 */}
{effTab !== "rank" && (
<div className="flex flex-col gap-2.5">
{shown.length === 0 && (
<p className="py-6 text-center text-[13px] text-white/45">
{effTab === "tournament"
? lang === "en"
? "Bracket is set after the group stage."
: "토너먼트 대진은 조별리그 종료 후 확정됩니다"
: lang === "en"
? "No matches"
: "경기가 없습니다"}
</p>
)}
{shown.map((m) => (
<MatchCard key={m.matchId} match={m} lang={lang} showDate={effTab === "team"} />
))}
</div>
)}
</section>
);
}
// 단일 줄 가로 스크롤 + 양 끝 화살표. 끝에 닿으면 해당 방향 화살표를 숨긴다.
// resetKey 가 바뀌면(탭 전환 등) 맨 앞으로 되감고 재측정한다.
function ChipScroller({
children,
resetKey,
}: {
children: React.ReactNode;
resetKey?: string | number;
}) {
const ref = useRef<HTMLDivElement>(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<HTMLElement>('[data-active="true"]');
el.scrollLeft = active
? active.offsetLeft - (el.clientWidth - active.clientWidth) / 2
: 0;
}
measure();
}, [resetKey]);
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 (
<div className="relative mb-4">
<div
ref={ref}
className="flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{children}
</div>
{!atStart && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pr-8 bg-gradient-to-r from-[var(--bg)] via-[var(--bg)] to-transparent">
<button
type="button"
aria-label="이전"
onClick={() => nudge(-1)}
className="pointer-events-auto grid h-7 w-7 place-items-center rounded-full border border-[var(--line-d)] bg-[var(--bg2)] text-[15px] leading-none text-white/80 shadow transition hover:text-white active:scale-95"
>
‹
</button>
</div>
)}
{!atEnd && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-end pl-8 bg-gradient-to-l from-[var(--bg)] via-[var(--bg)] to-transparent">
<button
type="button"
aria-label="다음"
onClick={() => nudge(1)}
className="pointer-events-auto grid h-7 w-7 place-items-center rounded-full border border-[var(--line-d)] bg-[var(--bg2)] text-[15px] leading-none text-white/80 shadow transition hover:text-white active:scale-95"
>
›
</button>
</div>
)}
</div>
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
data-active={active}
className={`shrink-0 whitespace-nowrap rounded-full border px-3.5 py-1.5 text-[13px] font-bold transition ${
active
? "border-[#94FBE0] text-[#94FBE0]"
: "border-[var(--line-d)] text-white/55"
}`}
>
{children}
</button>
);
}
// MLS 최근 5경기 폼 — "WWLDW" 를 W/D/L 색으로 표시
export function FormBadges({ form }: { form?: string | null }) {
if (!form) return <span className="text-white/35">?</span>;
return (
<span className="inline-flex gap-px font-mono font-extrabold tracking-tight">
{form.split("").map((c, i) => (
<span
key={i}
className={
c === "W"
? "text-[#94FBE0]"
: c === "L"
? "text-[#FF5B5B]/80"
: "text-white/45"
}
>
{c}
</span>
))}
</span>
);
}
// 팀명 옆 순위 배지 (야구·MLS — 순위 캐시 없으면 렌더 안 함)
function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) {
if (!rank) return null;
return (
<span className="shrink-0 rounded border border-white/15 px-1 py-px text-[10px] font-bold text-white/45">
{lang === "en" ? `#${rank}` : `${rank}위`}
</span>
);
}
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<LiveData | null>(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";
// 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동
const inner = (
<>
<div className="flex items-center justify-between text-[11px]">
<span className="font-mono font-bold text-white/70">
{showDate && (
<span className="text-white/55">{shortDate(dateKey(match.kickoffKst), lang)} </span>
)}
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span>
{isClub && match.venue ? (
<span className="text-white/40"> · {match.venue}</span>
) : match.group && match.roundLabel ? (
<> · {tRound(match.roundLabel, lang)}</>
) : null}
</span>
<span className="flex items-center gap-1.5">
{!isClub && (
<span className="text-[11px] font-bold text-white/45">
{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 ${
notStarted ? PHASE_CLS.scheduled : PHASE_CLS[phase]
}`}
>
{notStarted
? lang === "en" ? "Not started" : "시작 전"
: phase === "live" && liveBadge
? liveBadge
: t.phase[phase]}
</span>
</span>
</div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div className="flex items-center gap-2">
<TeamFlag team={left} className={flagCls} />
<span className="truncate text-[15px] font-extrabold">{teamShort(left, lang)}</span>
{isClub && <RankBadge rank={leftSt?.rank} lang={lang} />}
</div>
{match.result ? (
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
{leftScore}
<span className="px-1.5 text-white/40">:</span>
{rightScore}
</span>
) : phase === "live" && live?.score ? (
// 진행 중 실시간 스코어 — flip 시(MLS) 홈 먼저
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
{(flip ? live.score.home : live.score.away) ?? 0}
<span className="px-1.5 text-[#FF5B5B]">:</span>
{(flip ? live.score.away : live.score.home) ?? 0}
</span>
) : (
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
)}
<div className="flex items-center justify-end gap-2">
{isClub && <RankBadge rank={rightSt?.rank} lang={lang} />}
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(right, lang)}</span>
<TeamFlag team={right} className={flagCls} />
</div>
</div>
{/* 야구: 선발 매치업 (프리뷰 캐시 있을 때만) */}
{isBaseball && !match.result && (match.extras?.starterA || match.extras?.starterB) && (
<div className="mt-2 text-center text-[11px] font-semibold text-white/50">
<span className="text-white/35">{lang === "en" ? "SP" : "선발"}</span>{" "}
{match.extras?.starterA?.name ?? "?"}
<span className="px-1 text-white/30">vs</span>
{match.extras?.starterB?.name ?? "?"}
</div>
)}
{/* MLS: 최근 5경기 폼 (프리뷰 캐시 있을 때만) — 홈(왼쪽) 먼저 */}
{isMls && !match.result && (match.extras?.formA || match.extras?.formB) && (
<div className="mt-2 text-center text-[11px] font-semibold text-white/50">
<span className="text-white/35">{lang === "en" ? "Last 5" : "최근 5경기"}</span>{" "}
<FormBadges form={match.extras?.formB} />
<span className="px-1 text-white/30">vs</span>
<FormBadges form={match.extras?.formA} />
</div>
)}
{/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 (취소 경기는 숨김) */}
{match.votable && phase !== "cancelled" && (
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
<span>
<span className="font-semibold text-white/55">{t.aiPicks}</span>{" "}
{aiSplit(match, lang).join(" · ")}
</span>
<span className="flex items-center gap-2">
<span>{t.joined((match.crowd?.total ?? 0).toLocaleString())}</span>
<span className="text-[#94FBE0]">→</span>
</span>
</div>
)}
</>
);
const base = "block rounded-2xl border-2 bg-[#171b21] p-4";
// 취소 경기: 클릭 불가·저채도 표시 전용
if (phase === "cancelled") {
return <div className={`${base} border-[var(--line-d)] opacity-60`}>{inner}</div>;
}
if (match.votable && !finished) {
return (
<Link
to={`/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`}
className={`${base} border-[#94FBE0]/45 transition active:scale-[0.99] hover:border-[#94FBE0]`}
>
{inner}
</Link>
);
}
// 종료된 투표 경기는 결과 보기로, 비투표 경기는 표시 전용(클릭 시 상세=결과)
if (match.votable) {
return (
<Link to={`/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`} className={`${base} border-white/12`}>
{inner}
</Link>
);
}
return <div className={`${base} border-[var(--line-d)]`}>{inner}</div>;
}
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 : ["-"];
}