Voting window decided: open D-2 (kickoff-48h), close at kickoff
- types/schedule: add opensAt (kickoff-48h), lockAt = kickoff (정각) - Arena: 3-state gating (곧 오픈 / 활성 / 예측 마감), DEMO_FORCE_OPEN flag - seed.mjs, BACKEND.md, 기능정의서: lock semantics = kickoff, opensAt added - error codes: MATCH_NOT_OPEN (422) before opensAt, MATCH_LOCKED (423) at kickoff Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>main
parent
1b9dbd2276
commit
e43cc08429
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL } from "@/lib/mockData";
|
||||
import { outcomeLabel, pct } from "@/lib/format";
|
||||
import { MATCH, AI_PREDICTIONS, CROWD, LANDING_URL, DEMO_FORCE_OPEN } from "@/lib/mockData";
|
||||
import { outcomeLabel, pct, kickoffDisplay } from "@/lib/format";
|
||||
import type { Outcome, CrowdStats, ModelName } from "@/lib/types";
|
||||
|
||||
type Step = "pick" | "form" | "done";
|
||||
|
|
@ -29,7 +29,16 @@ export default function Arena() {
|
|||
const [crowd, setCrowd] = useState<CrowdStats>(CROWD);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const locked = Date.now() >= new Date(MATCH.lockAt).getTime();
|
||||
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각)
|
||||
const now = Date.now();
|
||||
const notOpen = !DEMO_FORCE_OPEN && now < new Date(MATCH.opensAt).getTime();
|
||||
const locked = now >= new Date(MATCH.lockAt).getTime();
|
||||
const disabled = locked || notOpen; // 폼 비활성
|
||||
const ctaLabel = notOpen
|
||||
? `투표 오픈 ${kickoffDisplay(MATCH.opensAt)}`
|
||||
: locked
|
||||
? "예측 마감 (경기 시작)"
|
||||
: "AI보다 잘 맞히기";
|
||||
|
||||
const matched = useMemo(() => {
|
||||
if (!outcome) return [];
|
||||
|
|
@ -156,7 +165,7 @@ export default function Arena() {
|
|||
).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
disabled={locked}
|
||||
disabled={disabled}
|
||||
onClick={() => setOutcome(val)}
|
||||
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition ${
|
||||
outcome === val
|
||||
|
|
@ -170,9 +179,9 @@ export default function Arena() {
|
|||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3">
|
||||
<Stepper label={MATCH.teamA.shortName} value={scoreA} onChange={setScoreA} disabled={locked} />
|
||||
<Stepper label={MATCH.teamA.shortName} value={scoreA} onChange={setScoreA} disabled={disabled} />
|
||||
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
|
||||
<Stepper label={MATCH.teamB.shortName} value={scoreB} onChange={setScoreB} disabled={locked} />
|
||||
<Stepper label={MATCH.teamB.shortName} value={scoreB} onChange={setScoreB} disabled={disabled} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -180,10 +189,10 @@ export default function Arena() {
|
|||
{step === "pick" && (
|
||||
<button
|
||||
onClick={submitPick}
|
||||
disabled={!outcome || locked}
|
||||
disabled={!outcome || disabled}
|
||||
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
|
||||
>
|
||||
{locked ? "예측 마감" : "AI보다 잘 맞히기"}
|
||||
{ctaLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@
|
|||
- **Firebase = 사용자가 새 프로젝트 직접 생성** → config 6키·서비스계정 추후 제공
|
||||
- **상금 챌린지(약관) = R2로 연기** → R1 백엔드는 픽 저장·채점·Crowd·이메일 알림까지만. `event_participation` 로직 보류
|
||||
- **결과 알림 = 이메일(Resend)**
|
||||
- **투표 창 = 오픈 킥오프 D-2(−48h) · 마감 킥오프 정각(경기 시작 전까지)**
|
||||
- 채점·상금 대상 픽은 경기 시작 전에 닫는다(블라인드 예측·AI vs 유저 동일 출발선·사행성 회피). 하프타임 등 경기 중 투표는 채택 안 함.
|
||||
- 필드: `matches.opensAt`(=킥오프−48h), `matches.lockAt`(=킥오프). 상태: scheduled→open→locked→live→finished.
|
||||
- 프론트 데모용 `DEMO_FORCE_OPEN`(mockData) — 운영 배포 시 false.
|
||||
|
||||
## 1. R1 백엔드 범위 (이번에 만들 것)
|
||||
1. 픽 제출 저장 (검증·중복방지·마감) — 콜러블 함수 `submitPrediction`
|
||||
|
|
@ -47,7 +51,7 @@ Next.js (Vercel) ──read──> Firestore (공개: matches/ai_predictions/c
|
|||
{ "ok":true,"predictionId":"A_KOR_CZE_20260612_uuid","matchedModels":["GPT"],
|
||||
"exactMatch":false,"crowd":{"total":1285,"teamAWin":616,"draw":347,"teamBWin":322} }
|
||||
```
|
||||
검증/에러: `MATCH_NOT_FOUND` · `MATCH_LOCKED`(now ≥ lockAt) · `INVALID_OUTCOME` · `INVALID_SCORE`(0–9 int) · `NICKNAME_INVALID`(2–16자·금칙어) · `ALREADY_SUBMITTED`(→ updatePrediction 안내)
|
||||
검증/에러: `MATCH_NOT_FOUND` · `MATCH_NOT_OPEN`(now < opensAt) · `MATCH_LOCKED`(now ≥ lockAt = 킥오프) · `INVALID_OUTCOME` · `INVALID_SCORE`(0–9 int) · `NICKNAME_INVALID`(2–16자·금칙어) · `ALREADY_SUBMITTED`(→ updatePrediction 안내)
|
||||
처리: 트랜잭션으로 ① docId=`matchId_deviceId` 없으면 create ② crowd_stats 증분 ③ matchedModels 계산.
|
||||
|
||||
### 3.2 updatePrediction (callable)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,6 @@ service cloud.firestore {
|
|||
}
|
||||
|
||||
// 참고: 클라이언트 직접 쓰기 방식을 택할 경우(함수 없이), user_predictions에
|
||||
// create 검증 규칙(킥오프 -10분 lock, outcome ∈ {TEAM_A_WIN,DRAW,TEAM_B_WIN},
|
||||
// create 검증 규칙(opens_at ≤ now < lock_at[=킥오프 정각], outcome ∈ {TEAM_A_WIN,DRAW,TEAM_B_WIN},
|
||||
// score 0~9 정수, nickname 2~16자, docId == matchId+"_"+deviceId)을 넣어야 한다.
|
||||
// 단 crowd_stats 원자적 증분과 PII 보호 때문에 콜러블 함수 방식을 권장한다(docs/BACKEND.md).
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { FEATURED } from "./schedule";
|
|||
|
||||
export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT)
|
||||
|
||||
// 데모용: 실제 운영은 투표 오픈 = 킥오프 D-2(opensAt). 팀 데모에서 픽이 막히지 않게
|
||||
// true면 오픈 게이트를 무시하고 즉시 참여 가능. 운영 배포 시 false 로.
|
||||
export const DEMO_FORCE_OPEN = true;
|
||||
|
||||
export const AI_PREDICTIONS: AIPrediction[] = [
|
||||
{
|
||||
matchId: MATCH.matchId,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ const CZE: Team = { name: "Czechia", shortName: "체코", code: "CZE", flag: "
|
|||
const MEX: Team = { name: "Mexico", shortName: "멕시코", code: "MEX", flag: "🇲🇽" };
|
||||
const RSA: Team = { name: "South Africa", shortName: "남아공", code: "RSA", flag: "🇿🇦" };
|
||||
|
||||
function lock(iso: string): string {
|
||||
return new Date(new Date(iso).getTime() - 10 * 60000).toISOString();
|
||||
// 투표 오픈 = 킥오프 -48시간 (D-2). 마감 = 킥오프 정각(경기 시작 전까지).
|
||||
function opens(iso: string): string {
|
||||
return new Date(new Date(iso).getTime() - 48 * 3600000).toISOString();
|
||||
}
|
||||
|
||||
// Group A 전체 6경기 — 경기별 정확 일시 자동 반영용 SSOT
|
||||
|
|
@ -21,7 +22,8 @@ export const GROUP_A: Match[] = [
|
|||
teamB: RSA,
|
||||
kickoffKst: "2026-06-12T04:00:00+09:00",
|
||||
venue: "Estadio Azteca · Mexico City",
|
||||
lockAt: lock("2026-06-12T04:00:00+09:00"),
|
||||
opensAt: opens("2026-06-12T04:00:00+09:00"),
|
||||
lockAt: "2026-06-12T04:00:00+09:00",
|
||||
status: "upcoming",
|
||||
hookText: "월드컵 개막전, AI는 개최국을 믿을까",
|
||||
result: null,
|
||||
|
|
@ -34,7 +36,8 @@ export const GROUP_A: Match[] = [
|
|||
teamB: CZE,
|
||||
kickoffKst: "2026-06-12T11:00:00+09:00",
|
||||
venue: "Estadio Guadalajara (Akron)",
|
||||
lockAt: lock("2026-06-12T11:00:00+09:00"),
|
||||
opensAt: opens("2026-06-12T11:00:00+09:00"),
|
||||
lockAt: "2026-06-12T11:00:00+09:00",
|
||||
status: "upcoming",
|
||||
hookText: "한국 첫 경기, AI의 선택은 갈렸다",
|
||||
result: null,
|
||||
|
|
@ -47,7 +50,8 @@ export const GROUP_A: Match[] = [
|
|||
teamB: RSA,
|
||||
kickoffKst: "2026-06-19T01:00:00+09:00",
|
||||
venue: "USA (TBD)",
|
||||
lockAt: lock("2026-06-19T01:00:00+09:00"),
|
||||
opensAt: opens("2026-06-19T01:00:00+09:00"),
|
||||
lockAt: "2026-06-19T01:00:00+09:00",
|
||||
status: "upcoming",
|
||||
hookText: "체코 vs 남아공, AI의 예측은",
|
||||
result: null,
|
||||
|
|
@ -60,7 +64,8 @@ export const GROUP_A: Match[] = [
|
|||
teamB: KOR,
|
||||
kickoffKst: "2026-06-19T10:00:00+09:00",
|
||||
venue: "Estadio Guadalajara (Akron)",
|
||||
lockAt: lock("2026-06-19T10:00:00+09:00"),
|
||||
opensAt: opens("2026-06-19T10:00:00+09:00"),
|
||||
lockAt: "2026-06-19T10:00:00+09:00",
|
||||
status: "upcoming",
|
||||
hookText: "개최국 멕시코 vs 한국, AI는 누구 편",
|
||||
result: null,
|
||||
|
|
@ -73,7 +78,8 @@ export const GROUP_A: Match[] = [
|
|||
teamB: MEX,
|
||||
kickoffKst: "2026-06-25T10:00:00+09:00",
|
||||
venue: "Estadio Azteca · Mexico City",
|
||||
lockAt: lock("2026-06-25T10:00:00+09:00"),
|
||||
opensAt: opens("2026-06-25T10:00:00+09:00"),
|
||||
lockAt: "2026-06-25T10:00:00+09:00",
|
||||
status: "upcoming",
|
||||
hookText: "체코 vs 멕시코, 운명의 최종전",
|
||||
result: null,
|
||||
|
|
@ -86,7 +92,8 @@ export const GROUP_A: Match[] = [
|
|||
teamB: KOR,
|
||||
kickoffKst: "2026-06-25T10:00:00+09:00",
|
||||
venue: "Estadio BBVA · Monterrey",
|
||||
lockAt: lock("2026-06-25T10:00:00+09:00"),
|
||||
opensAt: opens("2026-06-25T10:00:00+09:00"),
|
||||
lockAt: "2026-06-25T10:00:00+09:00",
|
||||
status: "upcoming",
|
||||
hookText: "한국 16강의 갈림길, AI의 마지막 예측",
|
||||
result: null,
|
||||
|
|
|
|||
12
lib/types.ts
12
lib/types.ts
|
|
@ -3,7 +3,14 @@
|
|||
|
||||
export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN";
|
||||
export type ModelName = "GPT" | "Claude" | "Gemini";
|
||||
export type MatchStatus = "upcoming" | "locked" | "live" | "finished";
|
||||
// scheduled: 오픈 전 / open: 투표 가능 / locked: 킥오프 후 마감 / live / finished
|
||||
export type MatchStatus =
|
||||
| "scheduled"
|
||||
| "open"
|
||||
| "locked"
|
||||
| "live"
|
||||
| "finished"
|
||||
| "upcoming";
|
||||
|
||||
export interface Team {
|
||||
name: string; // 표시명 (예: Korea Republic)
|
||||
|
|
@ -20,7 +27,8 @@ export interface Match {
|
|||
teamB: Team;
|
||||
kickoffKst: string; // ISO with +09:00
|
||||
venue: string;
|
||||
lockAt: string; // ISO (킥오프 -10분)
|
||||
opensAt: string; // ISO — 투표 오픈 (킥오프 -48h, D-2)
|
||||
lockAt: string; // ISO — 투표 마감 (킥오프 정각 = 경기 시작 전까지)
|
||||
status: MatchStatus;
|
||||
hookText: string; // "한국 첫 경기, AI의 선택은 갈렸다"
|
||||
result?: { scoreA: number; scoreB: number; outcome: Outcome } | null;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { getFirestore, FieldValue } from "firebase-admin/firestore";
|
|||
initializeApp({ credential: applicationDefault() });
|
||||
const db = getFirestore();
|
||||
|
||||
const lock = (iso) => new Date(new Date(iso).getTime() - 10 * 60000).toISOString();
|
||||
// 투표 오픈 = 킥오프 -48h(D-2), 마감 = 킥오프 정각(경기 시작 전까지)
|
||||
const opens = (iso) => new Date(new Date(iso).getTime() - 48 * 3600000).toISOString();
|
||||
const T = {
|
||||
KOR: { name: "Korea Republic", shortName: "한국", code: "KOR", flag: "🇰🇷" },
|
||||
CZE: { name: "Czechia", shortName: "체코", code: "CZE", flag: "🇨🇿" },
|
||||
|
|
@ -39,8 +40,9 @@ async function run() {
|
|||
for (const [matchId, round, a, b, kickoff, venue, hook] of MATCHES) {
|
||||
batch.set(db.collection("matches").doc(matchId), {
|
||||
matchId, roundLabel: round, group: "A", teamA: a, teamB: b,
|
||||
kickoffKst: kickoff, venue, lockAt: lock(kickoff), status: "upcoming",
|
||||
hookText: hook, result: null, updatedAt: FieldValue.serverTimestamp(),
|
||||
kickoffKst: kickoff, venue, opensAt: opens(kickoff), lockAt: kickoff,
|
||||
status: "scheduled", hookText: hook, result: null,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
// crowd_stats 초기화 (0)
|
||||
batch.set(db.collection("crowd_stats").doc(matchId), {
|
||||
|
|
|
|||
Loading…
Reference in New Issue