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
Haewon Kam 2026-06-09 11:18:26 +09:00
parent 1b9dbd2276
commit e43cc08429
7 changed files with 57 additions and 23 deletions

View File

@ -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>
)}

View File

@ -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`(09 int) · `NICKNAME_INVALID`(216자·금칙어) · `ALREADY_SUBMITTED`(→ updatePrediction 안내)
검증/에러: `MATCH_NOT_FOUND` · `MATCH_NOT_OPEN`(now < opensAt) · `MATCH_LOCKED`(now lockAt = 킥오프) · `INVALID_OUTCOME` · `INVALID_SCORE`(09 int) · `NICKNAME_INVALID`(216자·금칙어) · `ALREADY_SUBMITTED`(→ updatePrediction 안내)
처리: 트랜잭션으로 ① docId=`matchId_deviceId` 없으면 create ② crowd_stats 증분 ③ matchedModels 계산.
### 3.2 updatePrediction (callable)

View File

@ -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).

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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), {