전체일정 스코어 조정, utc 시간 -> kst 변경

develop
hbyang 2026-06-11 10:48:16 +09:00
parent 7185cfbb72
commit 8a4a23b0cb
9 changed files with 71 additions and 35 deletions

View File

@ -8,7 +8,7 @@ phase 는 now 기준 동적 계산 (lib/schedule.ts matchPhase 와 동일 규칙
"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from .config import settings
from .models import AIPrediction, CrowdStats, Match
@ -25,15 +25,32 @@ def now_utc() -> datetime:
return datetime.now(timezone.utc)
KST = timezone(timedelta(hours=9))
def ensure_aware(dt: datetime) -> datetime:
"""naive datetime(일부 DB 드라이버 반환)을 UTC aware 로 보정."""
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
def kst_iso(dt: datetime) -> str:
"""저장값(UTC)을 KST(+09:00) ISO 문자열로 직렬화 — 프론트 표시 기준 통일."""
return ensure_aware(dt).astimezone(KST).isoformat()
def compute_phase(m: Match, now: datetime | None = None) -> str:
"""투표/경기 단계:
finished 결과 입력됨 "종료"
live 킥오프 이후, 결과 입력 "경기중"
locked 투표 마감(킥오프 1h ) ~ 킥오프 "투표 종료"(비활성)
scheduled 오픈 "오픈 예정"
open 투표
"""
now = now or now_utc()
if m.result_outcome is not None:
return "finished"
if now >= ensure_aware(m.kickoff_at):
return "live"
if now >= ensure_aware(m.lock_at):
return "locked"
if now < ensure_aware(m.opens_at):
@ -110,18 +127,20 @@ def match_out(
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
for p in sorted(m.predictions, key=lambda x: order.get(x.model, 9)):
preds.append(prediction_out(p, lang))
# status·phase 를 동일한 실시간 계산값으로 통일 — 워커 틱 지연과 무관하게 일관.
phase = compute_phase(m, now)
return MatchOut(
matchId=m.match_id,
roundLabel=m.round_label,
group=m.group,
teamA=_team_a(m),
teamB=_team_b(m),
kickoffKst=m.kickoff_at.isoformat(),
kickoffKst=kst_iso(m.kickoff_at),
venue=m.venue,
opensAt=m.opens_at.isoformat(),
lockAt=m.lock_at.isoformat(),
status=m.status,
phase=compute_phase(m, now),
opensAt=kst_iso(m.opens_at),
lockAt=kst_iso(m.lock_at),
status=phase,
phase=phase,
votingOpen=is_open_for_voting(m, now),
hookText=m.hook_text,
result=result,

View File

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..domain import crowd_out, is_open_for_voting
from ..domain import compute_phase, crowd_out, is_open_for_voting
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
from ..scoring import outcome_of
from ..schemas import SubmitPredictionIn, SubmitPredictionOut
@ -50,10 +50,16 @@ async def submit_prediction(
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if not is_open_for_voting(match):
# 마감/종료/오픈전 구분
if match.result_outcome is not None:
raise HTTPException(status_code=409, detail="MATCH_FINISHED")
raise HTTPException(status_code=423, detail="MATCH_LOCKED")
# 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전
phase = compute_phase(match)
detail = {
"finished": "MATCH_FINISHED", # 결과 입력됨
"live": "MATCH_LIVE", # 경기중
"locked": "MATCH_LOCKED", # 투표 종료(킥오프 1h 전)
"scheduled": "MATCH_NOT_OPEN", # 아직 오픈 전
}.get(phase, "MATCH_LOCKED")
code = 409 if phase == "finished" else 423
raise HTTPException(status_code=code, detail=detail)
# outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화)
outcome = outcome_of(body.scoreA, body.scoreB)

View File

@ -6,7 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from .config import settings
@ -83,7 +83,8 @@ FEATURED_MATCH_ID = "A_KOR_CZE_20260612"
def parse_kickoff(iso: str) -> datetime:
return datetime.fromisoformat(iso)
# 저장은 UTC 로 통일 (DB 종류 무관하게 절대시점 일관). 출력 시 KST 로 직렬화.
return datetime.fromisoformat(iso).astimezone(timezone.utc)
def opens_at(kickoff: datetime) -> datetime:

View File

@ -55,8 +55,8 @@ class MatchOut(BaseModel):
venue: str
opensAt: str
lockAt: str
status: str
phase: str # scheduled | open | locked | finished (now 기준 계산)
status: str # = phase (실시간 계산값으로 통일)
phase: str # scheduled | open | locked | live | finished (now 기준 계산)
votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영)
hookText: str
result: MatchResult | None = None

View File

@ -27,7 +27,7 @@ from sqlalchemy.orm import selectinload
from .config import settings
from .database import SessionLocal, init_db
from .domain import ensure_aware, now_utc
from .domain import compute_phase, ensure_aware, now_utc
from .models import AIPrediction, Match, UserPrediction
from .scoring import load_scoring_data
from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable
@ -61,14 +61,9 @@ async def tick_status() -> None:
matches = (await db.execute(select(Match))).scalars().all()
changed = 0
for m in matches:
if m.result_outcome is not None:
target = "finished"
elif now >= ensure_aware(m.lock_at):
target = "locked"
elif now >= ensure_aware(m.opens_at):
target = "open"
else:
target = "scheduled"
# 화면용 phase 와 동일 기준으로 status 저장(단일 기준):
# scheduled → open → locked(투표종료) → live(경기중) → finished
target = compute_phase(m, now)
if m.status != target:
m.status = target
changed += 1

View File

@ -62,10 +62,12 @@ export default function Arena({
const disabled = finished || !match.votingOpen;
const ctaLabel = finished
? t.ctaResult
: match.phase === "scheduled" && !match.votingOpen
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
: match.phase === "live"
? t.ctaLive
: match.phase === "locked"
? t.ctaLocked
: match.phase === "scheduled" && !match.votingOpen
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
: t.ctaBeat;
const matched = useMemo(() => {

View File

@ -10,7 +10,8 @@ const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크
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-[var(--ink-muted)]",
locked: "border-[var(--line-d)] text-white/45", // 투표 종료 — 비활성 톤
live: "border-[#FF5B5B] text-[#FF5B5B]", // 경기중 — 라이브 강조
finished: "border-white/15 text-white/55",
};
@ -102,7 +103,16 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
</div>
{match.result ? (
// 종료된 경기 — 최종 스코어 (예: 3 : 2)
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
{match.result.scoreA}
<span className="px-1.5 text-white/40">:</span>
{match.result.scoreB}
</span>
) : (
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
)}
<div className="flex items-center justify-end gap-2">
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span>
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />

View File

@ -47,7 +47,7 @@ type Dict = {
prizeLine2: string;
schedTitle: string;
schedGuide: string;
phase: { open: string; scheduled: string; locked: string; finished: string };
phase: { open: string; scheduled: string; locked: string; live: string; finished: string };
aiPicks: string;
draw: string;
joined: (n: string) => string;
@ -61,6 +61,7 @@ type Dict = {
ctaBeat: string;
ctaResult: string;
ctaLocked: string;
ctaLive: string;
ctaOpens: (d: string) => string;
emailPh: string;
notify: string;
@ -104,7 +105,7 @@ export const DICT: Record<Lang, Dict> = {
prizeLine2: "최종 우승자에게 100만원 상금",
schedTitle: "전체 일정",
schedGuide: "투표하고 싶은 경기를 선택해주세요",
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 마감", finished: "종료" },
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 종료", live: "경기중", finished: "종료" },
aiPicks: "AI 픽",
draw: "무",
joined: (n) => `${n}명 참여`,
@ -117,7 +118,8 @@ export const DICT: Record<Lang, Dict> = {
drawOdds: "무승부 가능성",
ctaBeat: "AI보다 잘 맞히기",
ctaResult: "결과 보기",
ctaLocked: "예측 마감 (경기 시작)",
ctaLocked: "투표 종료",
ctaLive: "경기 진행 중",
ctaOpens: (d) => `투표 오픈 ${d}`,
emailPh: "이메일 (결과·다음 경기 알림)",
notify: "경기 결과가 나오면 알려주세요. 다음 경기·100만원 챌린지 소식도 받겠습니다.",
@ -161,7 +163,7 @@ export const DICT: Record<Lang, Dict> = {
prizeLine2: "overall winner takes ₩1,000,000",
schedTitle: "All Matches",
schedGuide: "Pick a match to predict",
phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", finished: "Ended" },
phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", live: "Live", finished: "Ended" },
aiPicks: "AI picks",
draw: "Draw",
joined: (n) => `${n} joined`,
@ -174,7 +176,8 @@ export const DICT: Record<Lang, Dict> = {
drawOdds: "Draw odds",
ctaBeat: "Beat the AI",
ctaResult: "View result",
ctaLocked: "Closed (kickoff)",
ctaLocked: "Voting closed",
ctaLive: "Match in progress",
ctaOpens: (d) => `Opens ${d}`,
emailPh: "Email (result & next-match alerts)",
notify: "Notify me when the result is out. I'll also get next-match & ₩1,000,000 challenge news.",

View File

@ -2,7 +2,7 @@
export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN";
export type ModelName = "GPT" | "Claude" | "Gemini";
export type MatchPhase = "scheduled" | "open" | "locked" | "finished";
export type MatchPhase = "scheduled" | "open" | "locked" | "live" | "finished";
export interface Team {
name: string;