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