전체일정 스코어 조정, utc 시간 -> kst 변경
parent
7185cfbb72
commit
8a4a23b0cb
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue