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

View File

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from ..database import get_db 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 ..models import AIPrediction, CrowdStats, Match, UserPrediction
from ..scoring import outcome_of from ..scoring import outcome_of
from ..schemas import SubmitPredictionIn, SubmitPredictionOut from ..schemas import SubmitPredictionIn, SubmitPredictionOut
@ -50,10 +50,16 @@ async def submit_prediction(
if not match: if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if not is_open_for_voting(match): if not is_open_for_voting(match):
# 마감/종료/오픈전 구분 # 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전
if match.result_outcome is not None: phase = compute_phase(match)
raise HTTPException(status_code=409, detail="MATCH_FINISHED") detail = {
raise HTTPException(status_code=423, detail="MATCH_LOCKED") "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 = outcome_of(body.scoreA, body.scoreB) outcome = outcome_of(body.scoreA, body.scoreB)

View File

@ -6,7 +6,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from .config import settings from .config import settings
@ -83,7 +83,8 @@ FEATURED_MATCH_ID = "A_KOR_CZE_20260612"
def parse_kickoff(iso: str) -> datetime: 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: def opens_at(kickoff: datetime) -> datetime:

View File

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

View File

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

View File

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

View File

@ -10,7 +10,8 @@ const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크
const PHASE_CLS: Record<MatchPhase, string> = { const PHASE_CLS: Record<MatchPhase, string> = {
open: "border-[#94FBE0] text-[#94FBE0]", open: "border-[#94FBE0] text-[#94FBE0]",
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]", 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", 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" /> <TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span> <span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
</div> </div>
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span> {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"> <div className="flex items-center justify-end gap-2">
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span> <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" /> <TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />

View File

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