diff --git a/backend/app/config.py b/backend/app/config.py index 59c6d49..58cd980 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -66,7 +66,11 @@ class Settings(BaseSettings): schedule_url: str = ( "https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json" ) - schedule_group: str = "Group A" # 추적할 그룹 (openfootball 라벨) + schedule_group: str = "Group A" # (구) openfootball 단일 그룹 라벨 — 폴백용 + # 전체 조별리그(12개조 72경기) 일정을 불러올지. 결과/스코어는 전 경기 표시. + schedule_all_groups: bool = True + # AI 예측·투표 대상 조(이 조만 예측 생성·투표 가능). 그 외는 일정/결과만 표시. + featured_group: str = "A" football_data_token: str = "" # football-data 사용 시 토큰 football_data_competition: str = "WC" # 결과(스코어) 소스 — 일정 소스와 분리. 빈값이면 schedule_source 사용. diff --git a/backend/app/domain.py b/backend/app/domain.py index de3c2ba..d551c9d 100644 --- a/backend/app/domain.py +++ b/backend/app/domain.py @@ -58,9 +58,16 @@ def compute_phase(m: Match, now: datetime | None = None) -> str: return "open" +def is_votable(m: Match) -> bool: + """AI 예측·투표 대상 조인지. featured_group 만 투표 가능, 그 외는 일정/결과만.""" + return m.group == settings.featured_group + + def is_open_for_voting(m: Match, now: datetime | None = None) -> bool: """제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈.""" now = now or now_utc() + if not is_votable(m): + return False if m.result_outcome is not None: return False if now >= ensure_aware(m.lock_at): @@ -141,6 +148,7 @@ def match_out( lockAt=kst_iso(m.lock_at), status=phase, phase=phase, + votable=is_votable(m), votingOpen=is_open_for_voting(m, now), hookText=m.hook_text, result=result, diff --git a/backend/app/routers/predictions.py b/backend/app/routers/predictions.py index 155b754..0230345 100644 --- a/backend/app/routers/predictions.py +++ b/backend/app/routers/predictions.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..database import get_db -from ..domain import compute_phase, crowd_out, is_open_for_voting +from ..domain import compute_phase, crowd_out, is_open_for_voting, is_votable from ..models import AIPrediction, CrowdStats, Match, UserPrediction from ..scoring import outcome_of from ..schemas import SubmitPredictionIn, SubmitPredictionOut @@ -49,6 +49,8 @@ async def submit_prediction( ).scalars().first() if not match: raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") + if not is_votable(match): + raise HTTPException(status_code=403, detail="MATCH_NOT_VOTABLE") if not is_open_for_voting(match): # 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전 phase = compute_phase(match) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1b6a8bd..1621f3e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -57,6 +57,7 @@ class MatchOut(BaseModel): lockAt: str status: str # = phase (실시간 계산값으로 통일) phase: str # scheduled | open | locked | live | finished (now 기준 계산) + votable: bool # AI 예측·투표 대상 조인지 (그 외는 일정/결과만) votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영) hookText: str result: MatchResult | None = None diff --git a/backend/app/services/schedule_fetch.py b/backend/app/services/schedule_fetch.py index a64df1b..a185e39 100644 --- a/backend/app/services/schedule_fetch.py +++ b/backend/app/services/schedule_fetch.py @@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone from ..config import settings from ..schedule_data import code_for +from ..teams_data import code_for_tla log = logging.getLogger("triplepick.schedule") @@ -95,8 +96,63 @@ async def _fetch_football_data() -> list[dict]: return out +async def _fetch_all_groups_football_data() -> list[dict]: + """football-data 에서 전체 조별리그(12개조 72경기) 수집. + + 반환: [{teamA, teamB, group('A'..'L'), kickoffKst, venue}] (tla→코드) + """ + if not settings.football_data_token: + log.warning("schedule(all): football-data 토큰 미설정 — 수집 생략") + return [] + import httpx + + url = ( + f"https://api.football-data.org/v4/competitions/" + f"{settings.football_data_competition}/matches" + ) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(url, headers={"X-Auth-Token": settings.football_data_token}) + r.raise_for_status() + data = r.json() + + 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' + ht = (m.get("homeTeam") or {}).get("tla") + at = (m.get("awayTeam") or {}).get("tla") + if not ht or not at: + continue + utc = m.get("utcDate") + if not utc: + continue + kickoff = ( + datetime.fromisoformat(utc.replace("Z", "+00:00")).astimezone(KST).isoformat() + ) + out.append( + { + "teamA": code_for_tla(ht), + "teamB": code_for_tla(at), + "group": g, + "kickoffKst": kickoff, + "venue": m.get("venue") or "", + } + ) + return out + + async def fetch_schedule() -> list[dict]: - """소스에 따라 일정 수집. 실패 시 빈 리스트(기존 일정 유지).""" + """일정 수집. 전체 조 모드면 football-data 전 조별리그, 아니면 단일 조.""" + if settings.schedule_all_groups: + try: + records = await _fetch_all_groups_football_data() + log.info("schedule: 전체 조별리그 %d경기 수집", len(records)) + if records: + return records + except Exception as e: # noqa: BLE001 + log.error("schedule(all) 실패: %s — 단일 조 폴백", e) + src = settings.schedule_source.lower() try: if src == "openfootball": diff --git a/backend/app/services/schedule_sync.py b/backend/app/services/schedule_sync.py index 3b0bb59..d669166 100644 --- a/backend/app/services/schedule_sync.py +++ b/backend/app/services/schedule_sync.py @@ -1,21 +1,25 @@ """수집한 일정을 DB에 반영 — 워커(스케줄링 서버)가 매일 호출. -매칭 키 = (teamA_code, teamB_code) 순서쌍 (그룹 라운드로빈에서 유일). -- 기존 경기: 킥오프/venue/투표시간(opens·lock) 갱신. 결과·예측·crowd·라운드/후킹은 보존. -- 신규 경기: matchId 생성 + crowd 초기화하여 삽입(예측은 워커 AI 생성이 채움). +전체 조별리그 모드: 12개조 72경기를 적재. 결과/스코어는 전 경기 표시. +- 기존 경기: (팀쌍, 순서 무관) 매칭 → 킥오프/venue/투표시간만 갱신. 결과·예측·crowd·팀명·라운드 보존. +- 신규 경기: teams_data(48개국)로 팀 구성 + matchId 생성 + crowd 초기화하여 삽입. 종료(result) 경기는 시각 변경하지 않음. """ from __future__ import annotations import logging +from datetime import timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from ..config import settings from ..models import CrowdStats, Match -from ..schedule_data import TEAMS, lock_at, opens_at, parse_kickoff +from ..schedule_data import lock_at, opens_at, parse_kickoff +from ..teams_data import team_info log = logging.getLogger("triplepick.schedule") +KST = timezone(timedelta(hours=9)) async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict: @@ -23,11 +27,16 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict: return {"updated": 0, "inserted": 0, "skipped": 0} existing = (await db.execute(select(Match))).scalars().all() - by_pair = {(m.team_a_code, m.team_b_code): m for m in existing} + # 순서 무관 매칭 — 소스의 홈/원정 순서가 우리와 달라도 기존 경기를 찾음(중복 방지) + 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 updated = inserted = skipped = 0 for rec in records: a, b = rec["teamA"], rec["teamB"] + group = rec.get("group", settings.featured_group) kickoff = parse_kickoff(rec["kickoffKst"]) m = by_pair.get((a, b)) if m is not None: @@ -41,29 +50,28 @@ async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict: m.venue = rec["venue"] updated += 1 else: - ta, tb = TEAMS.get(a), TEAMS.get(b) - if not ta or not tb: - skipped += 1 - continue - match_id = f"A_{a}_{b}_{kickoff.astimezone().strftime('%Y%m%d')}" - db.add( - Match( - match_id=match_id, - round_label="Match", - group="A", - team_a_name=ta["name"], team_a_short=ta["shortName"], - team_a_code=ta["code"], team_a_flag=ta["flag"], - team_b_name=tb["name"], team_b_short=tb["shortName"], - team_b_code=tb["code"], team_b_flag=tb["flag"], - venue=rec.get("venue", ""), - hook_text=f"{ta['shortName']} vs {tb['shortName']}, AI의 예측은", - kickoff_at=kickoff, - opens_at=opens_at(kickoff), - lock_at=lock_at(kickoff), - status="scheduled", - ) + 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}" + new = Match( + match_id=match_id, + round_label="", + group=group, + team_a_name=ta["name"], team_a_short=ta["shortName"], + team_a_code=ta["code"], team_a_flag=ta["flag"], + team_b_name=tb["name"], team_b_short=tb["shortName"], + team_b_code=tb["code"], team_b_flag=tb["flag"], + venue=rec.get("venue", ""), + hook_text=f"{ta['shortName']} vs {tb['shortName']}", + kickoff_at=kickoff, + opens_at=opens_at(kickoff), + lock_at=lock_at(kickoff), + status="scheduled", ) + 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 inserted += 1 await db.commit() diff --git a/backend/app/teams_data.py b/backend/app/teams_data.py new file mode 100644 index 0000000..d9a6557 --- /dev/null +++ b/backend/app/teams_data.py @@ -0,0 +1,76 @@ +"""2026 월드컵 48개국 팀 데이터 — 코드(대문자 FIFA) → 한글/약식/영문. + +국기는 frontend /assets/flags/{code소문자}.svg 를 사용(48개 자산 보유). +football-data tla → 코드 변환은 TLA_REMAP(대부분 동일, CUR/URY만 예외). +""" +from __future__ import annotations + +# football-data tla → 내부 코드(=FIFA 국기 코드). 대부분 동일, 예외만 매핑. +TLA_REMAP = {"CUR": "CUW", "URY": "URU"} + + +def code_for_tla(tla: str) -> str: + tla = (tla or "").strip().upper() + return TLA_REMAP.get(tla, tla) + + +# 코드 → {ko(전체명), short(약식), en} +TEAMS: dict[str, dict] = { + "ALG": {"ko": "알제리", "short": "알제리", "en": "Algeria"}, + "ARG": {"ko": "아르헨티나", "short": "아르헨티나", "en": "Argentina"}, + "AUS": {"ko": "호주", "short": "호주", "en": "Australia"}, + "AUT": {"ko": "오스트리아", "short": "오스트리아", "en": "Austria"}, + "BEL": {"ko": "벨기에", "short": "벨기에", "en": "Belgium"}, + "BIH": {"ko": "보스니아 헤르체고비나", "short": "보스니아", "en": "Bosnia-Herzegovina"}, + "BRA": {"ko": "브라질", "short": "브라질", "en": "Brazil"}, + "CAN": {"ko": "캐나다", "short": "캐나다", "en": "Canada"}, + "CIV": {"ko": "코트디부아르", "short": "코트디부아르", "en": "Ivory Coast"}, + "COD": {"ko": "콩고민주공화국", "short": "콩고DR", "en": "Congo DR"}, + "COL": {"ko": "콜롬비아", "short": "콜롬비아", "en": "Colombia"}, + "CPV": {"ko": "카보베르데", "short": "카보베르데", "en": "Cape Verde"}, + "CRO": {"ko": "크로아티아", "short": "크로아티아", "en": "Croatia"}, + "CUW": {"ko": "퀴라소", "short": "퀴라소", "en": "Curaçao"}, + "CZE": {"ko": "체코", "short": "체코", "en": "Czechia"}, + "ECU": {"ko": "에콰도르", "short": "에콰도르", "en": "Ecuador"}, + "EGY": {"ko": "이집트", "short": "이집트", "en": "Egypt"}, + "ENG": {"ko": "잉글랜드", "short": "잉글랜드", "en": "England"}, + "ESP": {"ko": "스페인", "short": "스페인", "en": "Spain"}, + "FRA": {"ko": "프랑스", "short": "프랑스", "en": "France"}, + "GER": {"ko": "독일", "short": "독일", "en": "Germany"}, + "GHA": {"ko": "가나", "short": "가나", "en": "Ghana"}, + "HAI": {"ko": "아이티", "short": "아이티", "en": "Haiti"}, + "IRN": {"ko": "이란", "short": "이란", "en": "Iran"}, + "IRQ": {"ko": "이라크", "short": "이라크", "en": "Iraq"}, + "JOR": {"ko": "요르단", "short": "요르단", "en": "Jordan"}, + "JPN": {"ko": "일본", "short": "일본", "en": "Japan"}, + "KOR": {"ko": "대한민국", "short": "한국", "en": "Korea Republic"}, + "KSA": {"ko": "사우디아라비아", "short": "사우디", "en": "Saudi Arabia"}, + "MAR": {"ko": "모로코", "short": "모로코", "en": "Morocco"}, + "MEX": {"ko": "멕시코", "short": "멕시코", "en": "Mexico"}, + "NED": {"ko": "네덜란드", "short": "네덜란드", "en": "Netherlands"}, + "NOR": {"ko": "노르웨이", "short": "노르웨이", "en": "Norway"}, + "NZL": {"ko": "뉴질랜드", "short": "뉴질랜드", "en": "New Zealand"}, + "PAN": {"ko": "파나마", "short": "파나마", "en": "Panama"}, + "PAR": {"ko": "파라과이", "short": "파라과이", "en": "Paraguay"}, + "POR": {"ko": "포르투갈", "short": "포르투갈", "en": "Portugal"}, + "QAT": {"ko": "카타르", "short": "카타르", "en": "Qatar"}, + "RSA": {"ko": "남아프리카 공화국", "short": "남아공", "en": "South Africa"}, + "SCO": {"ko": "스코틀랜드", "short": "스코틀랜드", "en": "Scotland"}, + "SEN": {"ko": "세네갈", "short": "세네갈", "en": "Senegal"}, + "SUI": {"ko": "스위스", "short": "스위스", "en": "Switzerland"}, + "SWE": {"ko": "스웨덴", "short": "스웨덴", "en": "Sweden"}, + "TUN": {"ko": "튀니지", "short": "튀니지", "en": "Tunisia"}, + "TUR": {"ko": "튀르키예", "short": "튀르키예", "en": "Turkey"}, + "URU": {"ko": "우루과이", "short": "우루과이", "en": "Uruguay"}, + "USA": {"ko": "미국", "short": "미국", "en": "United States"}, + "UZB": {"ko": "우즈베키스탄", "short": "우즈벡", "en": "Uzbekistan"}, +} + + +def team_info(code: str) -> dict: + c = (code or "").strip().upper() + t = TEAMS.get(c) + if t: + return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": ""} + # 미등록 코드 폴백 + return {"name": c, "shortName": c, "code": c, "flag": ""} diff --git a/backend/app/worker.py b/backend/app/worker.py index a6a09c6..5f43c3a 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -83,10 +83,14 @@ async def generate_ai_predictions(only_missing: bool = True) -> None: only_missing=False: 전부 재생성(덮어쓰기) — 수동 재생성 스크립트용. """ async with SessionLocal() as db: + # AI 예측·투표 대상 조(featured_group)만 생성. 그 외 조는 일정/결과만. matches = ( await db.execute( select(Match) - .where(Match.result_outcome.is_(None)) + .where( + Match.result_outcome.is_(None), + Match.group == settings.featured_group, + ) .options(selectinload(Match.predictions)) ) ).scalars().all() diff --git a/frontend/src/components/ScheduleBoard.tsx b/frontend/src/components/ScheduleBoard.tsx index 7417147..a7d7866 100644 --- a/frontend/src/components/ScheduleBoard.tsx +++ b/frontend/src/components/ScheduleBoard.tsx @@ -1,31 +1,28 @@ +import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { dateHeading, timeOnly } from "@/lib/format"; -import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n"; +import { timeOnly, dateKey } from "@/lib/format"; +import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n"; import type { Match, MatchPhase } from "@/lib/types"; -import { dateKey } from "@/lib/format"; import TeamFlag from "./TeamFlag"; -const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러 +// 칩용 짧은 날짜: "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-[var(--line-d)] text-[var(--ink-muted)]", - locked: "border-[var(--line-d)] text-white/45", // 투표 종료 — 비활성 톤 - live: "border-[#FF5B5B] text-[#FF5B5B]", // 경기중 — 라이브 강조 + locked: "border-[var(--line-d)] text-white/45", + live: "border-[#FF5B5B] text-[#FF5B5B]", finished: "border-white/15 text-white/55", }; -// 날짜별로 묶고 킥오프 오름차순 정렬 (백엔드가 이미 정렬 반환) -function groupByDate(matches: Match[]): { date: string; matches: Match[] }[] { - const groups: { date: string; matches: Match[] }[] = []; - for (const m of matches) { - const key = dateKey(m.kickoffKst); - const last = groups[groups.length - 1]; - if (last && last.date === key) last.matches.push(m); - else groups.push({ date: key, matches: [m] }); - } - return groups; -} +type Tab = "date" | "group"; export default function ScheduleBoard({ matches, @@ -35,66 +32,137 @@ export default function ScheduleBoard({ lang?: Lang; }) { const t = dict(lang); - const groups = groupByDate(matches); + const [tab, setTab] = useState("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조 + const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []); + const [selDate, setSelDate] = useState( + () => (dates.includes(todayKey) ? todayKey : dates[0]) ?? "", + ); + const [selGroup, setSelGroup] = useState( + () => (groups.includes("A") ? "A" : groups[0]) ?? "", + ); + + const shown = useMemo(() => { + const list = + tab === "date" + ? matches.filter((m) => dateKey(m.kickoffKst) === selDate) + : matches.filter((m) => m.group === selGroup); + return [...list].sort( + (a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(), + ); + }, [matches, tab, selDate, selGroup]); + + const tabLabel = (k: Tab) => + k === "date" ? (lang === "en" ? "By date" : "날짜별") : lang === "en" ? "Groups" : "조별리그"; + return (

{t.schedTitle}

- - {t.schedGuide} - + {t.schedGuide}
-
- {groups.map((g) => ( -
-
- {dateHeading(g.date, lang)} -
-
- {g.matches.map((m) => ( - - ))} -
-
+ {/* 탭: 날짜별 / 조별리그 */} +
+ {(["date", "group"] as Tab[]).map((k) => ( + + ))} +
+ + {/* 칩: 날짜 또는 조 */} +
+ {tab === "date" + ? dates.map((d) => ( + setSelDate(d)}> + {shortDate(d, lang)} + + )) + : groups.map((g) => ( + setSelGroup(g)}> + {lang === "en" ? `Group ${g}` : `${g}조`} + + ))} +
+ + {/* 경기 카드 */} +
+ {shown.length === 0 && ( +

+ {lang === "en" ? "No matches" : "경기가 없습니다"} +

+ )} + {shown.map((m) => ( + ))}
); } +function Chip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + function MatchCard({ match, lang }: { match: Match; lang: Lang }) { const t = dict(lang); const phase = match.phase; - const preds = match.predictions; - const crowdTotal = match.crowd?.total ?? 0; + const finished = !!match.result; - // AI 픽 갈림 요약 - const tally = { a: 0, d: 0, b: 0 }; - for (const p of preds) { - if (p.outcome === "TEAM_A_WIN") tally.a++; - else if (p.outcome === "DRAW") tally.d++; - else tally.b++; - } - const split: string[] = []; - if (tally.a) split.push(`${teamShort(match.teamA, lang)} ${tally.a}`); - if (tally.d) split.push(`${t.draw} ${tally.d}`); - if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`); - - const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`; - - return ( - + // 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동 + const inner = ( + <>
- {timeOnly(match.kickoffKst)} KST ·{" "} - {tRound(match.roundLabel, lang)} + {timeOnly(match.kickoffKst)} KST + {match.roundLabel ? <> · {tRound(match.roundLabel, lang)} : null} - - {t.phase[phase]} + + + {lang === "en" ? `Group ${match.group}` : `${match.group}조`} + + + {t.phase[phase]} +
@@ -104,7 +172,6 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) { {teamShort(match.teamA, lang)} {match.result ? ( - // 종료된 경기 — 최종 스코어 (예: 3 : 2) {match.result.scoreA} : @@ -119,15 +186,55 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) { -
- - {t.aiPicks} {split.join(" · ")} - - - {t.joined(crowdTotal.toLocaleString())} - - -
- + {/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 */} + {match.votable && ( +
+ + {t.aiPicks}{" "} + {aiSplit(match, lang).join(" · ")} + + + {t.joined((match.crowd?.total ?? 0).toLocaleString())} + + +
+ )} + ); + + const base = "block rounded-2xl border-2 bg-[#171b21] p-4"; + 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 out: string[] = []; + if (tally.a) out.push(`${teamShort(match.teamA, lang)} ${tally.a}`); + if (tally.d) out.push(`${t.draw} ${tally.d}`); + if (tally.b) out.push(`${teamShort(match.teamB, lang)} ${tally.b}`); + return out.length ? out : ["-"]; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 18cc4f7..855852d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -48,6 +48,7 @@ export interface Match { lockAt: string; status: string; phase: MatchPhase; + votable: boolean; votingOpen: boolean; hookText: string; result?: MatchResult | null;