From a0fc9d6194268c7b4a5b4c519cce3fa4d59ba657 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Tue, 9 Jun 2026 14:05:14 +0900 Subject: [PATCH] Multi-match restructure + 06.09 meeting reflection + scoring design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조: - 단일 경기 → 멀티 경기: 대시보드(/) + 경기 상세(/match/[matchId], 6경기 SSG) + 리더보드 stub(/leaderboard) - 컴포넌트 파라미터화: MatchupHUD/Arena가 match·predictions·crowd props 수신 (싱글톤 의존 제거) - 신규 컴포넌트: ScheduleBoard, Countdown, TeamFlag - mockData: 경기별 결정론적 AI예측/Crowd 생성기(getPredictions/getCrowd) + matchUrl 06.09 회의 반영: - 확률 라벨 "OO 승리 확률 %"+승리팀 국기(D3), confidence 바 제거 + 메인 리즌 강조 - 닉네임 제거→이메일만(D5), 투표 종료 카운트다운(D7), 결과 보기 전환, 경기별 공유(D8) - "월드컵" 표현 제거→글로벌 축구(D9), 서브타이틀 중복 삭제, Crowd "AI 승리 확률과 다름" 라벨 - 포인트 컬러 #4AFFA0 통일, 일정 #94FBE0, 푸터 가시성 개선 채점·심사 설계(docs/SCORING.md): - 공정성 모델 확정: 주최측 배제 + 순수 누적 포인트(점수는 정확도에서만) - 배점/랭킹/배제/결과정의 + scorePrediction·rank 참조 구현 - 골드 카드 심사 기준 카피 정합 Co-Authored-By: Claude Opus 4.8 --- HANDOFF.md | 61 +++++--- app/globals.css | 6 +- app/layout.tsx | 8 +- app/leaderboard/page.tsx | 26 ++++ app/match/[matchId]/page.tsx | 66 ++++++++ app/page.tsx | 23 ++- components/Arena.tsx | 283 ++++++++++++++++++++--------------- components/BallIcon.tsx | 2 +- components/Countdown.tsx | 53 +++++++ components/Footer.tsx | 8 +- components/Hero.tsx | 28 ++-- components/MatchupHUD.tsx | 81 +++++----- components/ScheduleBoard.tsx | 98 ++++++++++++ components/TeamFlag.tsx | 46 ++++++ docs/DESIGN.md | 26 +++- docs/SCORING.md | 165 ++++++++++++++++++++ lib/format.ts | 36 ++++- lib/mockData.ts | 124 +++++++++++++-- lib/schedule.ts | 29 +++- 19 files changed, 925 insertions(+), 244 deletions(-) create mode 100644 app/leaderboard/page.tsx create mode 100644 app/match/[matchId]/page.tsx create mode 100644 components/Countdown.tsx create mode 100644 components/ScheduleBoard.tsx create mode 100644 components/TeamFlag.tsx create mode 100644 docs/SCORING.md diff --git a/HANDOFF.md b/HANDOFF.md index 6ec1d2c..8c7e1f4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,19 +7,24 @@ 1. **이 파일** (현재 상태·파일맵·실행) 2. `docs/DESIGN.md` — **룩앤필/디자인 시스템 SSOT** (컬러·폰트·컴포넌트·자산·Do/Don't) 3. `../Worldcup 2026/000. Plan/Task — 랜딩·디자인·프론트엔드 업데이트 (06.09 회의).md` — **할 일 전체(우선순위·금요일 스코프)** -4. `docs/BACKEND.md` — 백엔드(Firestore·함수·채점·Resend·투표창) -5. (참고) `../Worldcup 2026/000. Plan/Landing 기능정의서 — TriplePick 2026.md` / `Landing PRD — TriplePick 2026.md` +4. `docs/SCORING.md` — **참여자 채점·심사 SSOT** (공정성 3원칙·배점·랭킹·100만원 심사) ★신규 +5. `docs/BACKEND.md` — 백엔드(Firestore·함수·채점·Resend·투표창) +6. (참고) `../Worldcup 2026/000. Plan/Landing 기능정의서 — TriplePick 2026.md` / `Landing PRD — TriplePick 2026.md` ## 0. 프로젝트 TriplePick = "AI(GPT·Claude·Gemini) vs 너" 글로벌 축구 승부예측 랜딩. (※ "월드컵" 명칭 법적 사용불가) 픽 제출 → 같은 AI 확인 → 공유 → 포인트/100만원 챌린지. 모바일 우선. ## 1. 현재 상태 (DONE) -- Next.js 16 + React 19 + Tailwind v4 (App Router, TS). `npm run build` 통과. -- **단일 경기(한국 vs 체코) v2** 구현 — 디자인 002/003 + 사용자 디렉션 반영(Impact, 민트 #85FCA8, 틸그린 GPT, 클래식 축구공, 화이트 시트, 쿨톤 상금). -- 인터랙션: 픽 → (닉네임)이메일 → 제출 → 결과(같은 AI) → Crowd 증가 (전부 **mock**, 백엔드 미연결). -- 배포: https://triplepick-web.vercel.app (`/` = 현재, `/before` = v1 기록용). -- git: `fa68169` init → `5ba653c` /before → `1b9dbd2` Backend Pack → (투표창) 커밋들. +- Next.js 16 + React 19 + Tailwind v4 (App Router, TS). `npm run build` 통과(12 routes, 6경기 SSG). +- **멀티 경기 구조 (06.09 회의 반영, 2026-06-09)** — 대시보드 + 경기 상세 + 리더보드 stub. 프론트엔드 §1·§2·§3 완료. + - `/` = 전체 일정 대시보드, `/match/[matchId]` = 경기 상세(6경기), `/leaderboard` = 차기 stub, `/before` = v1 기록용. + - 상세: 확률 라벨 "OO 승리 확률 %"+국기, 메인 리즌 강조, 이메일 전용 폼, **투표 종료 카운트다운**, 경기별 공유, 결과 보기 분기. + - "월드컵" 표현 전면 제거 → "글로벌 축구". Crowd엔 "AI 승리 확률과 다름" 라벨. +- 디자인 002/003 토큰 유지(Impact, 민트 #85FCA8, 틸그린 GPT, 클래식 축구공, 화이트 시트, 쿨톤 상금). +- 데이터 전부 **mock**(`getPredictions`/`getCrowd`, 결정론 생성), 백엔드 미연결. +- 배포: https://triplepick-web.vercel.app +- git: `fa68169` init → `5ba653c` /before → `1b9dbd2` Backend Pack → (투표창) → **(멀티경기 06.09) 커밋 대기**. ## 2. 실행 ```bash @@ -30,19 +35,25 @@ npm run dev -- --port 3737 # http://localhost:3737 ## 3. 파일 맵 (현행) ``` -app/layout.tsx 메타·OG·SEO(ko) -app/page.tsx Hero → MatchupHUD → Arena → Footer (단일 경기) -app/before/page.tsx /before (v1 기록용, 자체완결) -app/globals.css 디자인 토큰 (DESIGN.md와 1:1) -lib/types.ts 도메인 타입 (opensAt/lockAt 포함) -lib/schedule.ts Group A 6경기 일정(KST) SSOT — opensAt=킥오프-48h, lockAt=킥오프 -lib/mockData.ts ⚠️ MOCK AI예측/Crowd + DEMO_FORCE_OPEN 플래그 -lib/format.ts 시간·라벨 포맷 -lib/firebase.ts 클라 초기화 스켈레톤(env) -components/Hero.tsx · MatchupHUD.tsx · Arena.tsx(핵심) · BallIcon.tsx · Footer.tsx +app/layout.tsx 메타·OG·SEO(ko, 브랜드 레벨) +app/page.tsx L1 대시보드: Hero → ScheduleBoard → Footer +app/match/[matchId]/page.tsx L2 경기 상세(SSG, generateStaticParams 6경기) + generateMetadata +app/leaderboard/page.tsx L3 리더보드 stub (차기) +app/before/page.tsx /before (v1 기록용, 자체완결) +app/globals.css 디자인 토큰 (DESIGN.md와 1:1) +lib/types.ts 도메인 타입 (opensAt/lockAt 포함) +lib/schedule.ts Group A 6경기 SSOT + matchPhase() + matchesByDate() +lib/mockData.ts ⚠️ MOCK — getPredictions()/getCrowd() 경기별, matchUrl(), DEMO_FORCE_OPEN +lib/format.ts 시간·라벨 + winProb()(D3) + dateHeading/timeOnly/dateKey +lib/firebase.ts 클라 초기화 스켈레톤(env) +components/ + Hero.tsx 브랜드 헤더(back 옵션) ScheduleBoard.tsx 대시보드 경기 리스트 + MatchupHUD.tsx 대결 카드({match}) Arena.tsx 핵심({match,predictions,crowd,shareUrl}) + Countdown.tsx 투표 종료 카운트다운(D7) TeamFlag.tsx 국기 일반화(KOR png/CZE svg/이모지) + BallIcon.tsx · Footer.tsx public/icons/ ball.png(투명) profile.png gpt/claude/gemini kor.png firestore.rules · firestore.indexes.json · firebase.json · .env.example · scripts/seed.mjs -docs/DESIGN.md · docs/BACKEND.md +docs/DESIGN.md · docs/SCORING.md(채점·심사 SSOT) · docs/BACKEND.md ``` ## 4. 확정 결정 (구현에 반영됨/반영할 것) @@ -50,14 +61,16 @@ docs/DESIGN.md · docs/BACKEND.md - **AI 예측**: 매일 KST 0시 1회 생성, 그날 고정(immutable), 남은 경기만. (현재 mock) - **결과 메일**: 경기 3시간 후, **맞춘 사람만** + 다음경기/100만원 링크 (Resend). - **Firebase**: 사용자가 새 프로젝트 직접 생성 → config 6키·서비스계정·Resend키 제공 대기. -- **상금 약관(포인트 배점 수치)**: 미정 → 팀 결정 후 dev. R2. +- **채점·심사(100만원)**: **주최측 배제 + 순수 누적 포인트**(점수는 정확도에서만) 확정 → `docs/SCORING.md`. 배점 수치(5/3/2/1/0)만 황 본부장 확정 대기. ## 5. 다음 세션 할 일 (우선순위) -### 🔴 A. 멀티 경기 구조 (06.09 회의 최대 변경) -- 단일 `/` → **전체 일정 대시보드** + `/match/[matchId]` 상세 + (차기)`/leaderboard` -- `lib/schedule.ts` 6경기를 소스로 대시보드/동적 상세 렌더 -### 🔴 B. 경기 상세 변경 (금요일 핵심) -- 확률 라벨 "승리 예측 팀 승리 확률"로, 닉네임 제거(이메일만), 서브타이틀 삭제, 투표 카운트다운 복원, 예측하기→결과보기 전환, 경기별 공유 +### ✅ A. 멀티 경기 구조 — DONE (2026-06-09) +- 대시보드(`/`) + `/match/[matchId]` 상세(6경기 SSG) + `/leaderboard` stub. `schedule.ts` 6경기 소스. +### ✅ B. 경기 상세 변경 — DONE (2026-06-09) +- 확률 라벨(D3)·이메일만(D5)·서브타이틀 삭제·카운트다운 복원(D7)·결과보기 분기·경기별 공유(D8) 전부 반영. +### 🔴 B'. 백엔드 연동 대기 데이터 (팀 결정 → dev) +- **채점 로직**: `docs/SCORING.md` 그대로 구현(`scorePrediction`/`rank`). 모델=주최측 배제+순수 누적 포인트 확정. **배점 수치(5/3/2/1/0)만** 황 본부장 확정 후 `SCORE` 상수 교체. +- **AI reason 실제 모델 출력**으로 mock 교체. **결과 메일 3시간 후**(D6) 파이프라인. ### 🟠 C. Firebase 연결 (BACKEND.md 그대로) - `npm i firebase`(설치됨) → `.env.local` 6키 → `mockData` 읽기 → Firestore, `Arena.confirmSubmit` mock → `submitPrediction` 콜러블 - `firebase deploy --only firestore:rules,firestore:indexes` → `node scripts/seed.mjs` → functions 생성/구현 diff --git a/app/globals.css b/app/globals.css index 5c3cac2..339117a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5,7 +5,7 @@ --bg2: #1b1f26; --mint: #85fca8; /* CTA 버튼 */ --mint-ink: #0a2417; /* 민트 버튼 위 텍스트 */ - --green: #3ee08a; /* 글로우/액센트 */ + --green: #4affa0; /* 글로우/액센트 (포인트 컬러) */ --sheet: #f2f3ef; /* 레이어드 화이트 시트 */ --sheet-card: #ffffff; @@ -116,8 +116,8 @@ body { .vs-glow { background: radial-gradient( closest-side, - rgba(62, 224, 138, 0.5), - rgba(62, 224, 138, 0) 70% + rgba(74, 255, 160, 0.5), + rgba(74, 255, 160, 0) 70% ); } diff --git a/app/layout.tsx b/app/layout.tsx index 24fbaf5..3e5c100 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,12 +2,12 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "한국 vs 체코 AI 승부예측 | GPT vs Claude vs Gemini — TriplePick", + title: "TriplePick 2026 | AI 글로벌 축구 승부예측 — GPT vs Claude vs Gemini", description: - "GPT·Claude·Gemini가 한국 vs 체코를 서로 다르게 예측했습니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 맞히면 100만 원 챌린지.", + "GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.", applicationName: "TriplePick", openGraph: { - title: "AI 셋이 갈렸다 — 한국 vs 체코, 당신의 픽은?", + title: "AI 셋이 갈렸다 — 당신의 픽은?", description: "GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어보세요. TriplePick AI 예측 아레나.", siteName: "TriplePick", @@ -16,7 +16,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "AI 셋이 갈렸다 — 한국 vs 체코", + title: "AI 셋이 갈렸다 — TriplePick 2026", description: "GPT vs Claude vs Gemini vs 너. 지금 픽하고 AI와 겨뤄요.", }, }; diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..6c1631c --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import Hero from "@/components/Hero"; +import Footer from "@/components/Footer"; + +// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub. +export const metadata: Metadata = { + title: "랭킹 | TriplePick 2026", + description: "TriplePick 누적 랭킹 — 곧 공개됩니다.", +}; + +export default function LeaderboardPage() { + return ( +
+ +
+
LEADERBOARD
+

랭킹은 곧 공개됩니다

+

+ 매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 + 최종 100만원을 드립니다. +

+
+
+
+ ); +} diff --git a/app/match/[matchId]/page.tsx b/app/match/[matchId]/page.tsx new file mode 100644 index 0000000..70c6860 --- /dev/null +++ b/app/match/[matchId]/page.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Hero from "@/components/Hero"; +import MatchupHUD from "@/components/MatchupHUD"; +import Arena from "@/components/Arena"; +import Footer from "@/components/Footer"; +import { GROUP_A } from "@/lib/schedule"; +import { getPredictions, getCrowd, matchUrl } from "@/lib/mockData"; + +// L2. 경기 상세(대결) 페이지 — 6경기 정적 생성 +export function generateStaticParams() { + return GROUP_A.map((m) => ({ matchId: m.matchId })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ matchId: string }>; +}): Promise { + const { matchId } = await params; + const match = GROUP_A.find((m) => m.matchId === matchId); + if (!match) return { title: "TriplePick 2026" }; + const title = `${match.teamA.shortName} vs ${match.teamB.shortName} AI 승부예측 | TriplePick`; + const desc = `GPT·Claude·Gemini가 ${match.teamA.name} vs ${match.teamB.name}를 서로 다르게 예측했습니다. 당신의 픽을 찍고 AI와 겨뤄보세요.`; + return { + title, + description: desc, + openGraph: { title, description: desc, siteName: "TriplePick", type: "website", locale: "ko_KR" }, + twitter: { card: "summary_large_image", title, description: desc }, + }; +} + +export default async function MatchPage({ + params, +}: { + params: Promise<{ matchId: string }>; +}) { + const { matchId } = await params; + const match = GROUP_A.find((m) => m.matchId === matchId); + if (!match) notFound(); + + const predictions = getPredictions(match); + const crowd = getCrowd(match); + + return ( +
+ + + {/* 경기별 후킹 카피 (한 줄) */} +

+ + {match.hookText} + +

+ + + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index b047895..d863a59 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,14 +1,29 @@ import Hero from "@/components/Hero"; -import MatchupHUD from "@/components/MatchupHUD"; -import Arena from "@/components/Arena"; +import ScheduleBoard from "@/components/ScheduleBoard"; import Footer from "@/components/Footer"; +// L1. 전체 일정 대시보드 (랜딩 진입) export default function Home() { return (
- - + + {/* 기간 안내 + CTA 영역 */} +
+

+ GPT · Claude · Gemini가 매 경기를 +
+ 서로 다르게 예측합니다. +
+ + 가장 많이 참여하고, 가장 정확한 최종 우승자에게 +
+ 100만원 상금 +
+

+
+ +
); diff --git a/components/Arena.tsx b/components/Arena.tsx index a4417ba..bee773a 100644 --- a/components/Arena.tsx +++ b/components/Arena.tsx @@ -1,59 +1,75 @@ "use client"; import { useMemo, useState } from "react"; -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"; +import { DEMO_FORCE_OPEN } from "@/lib/mockData"; +import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format"; +import type { + Outcome, + CrowdStats, + ModelName, + Match, + AIPrediction, +} from "@/lib/types"; +import Countdown from "./Countdown"; +import TeamFlag from "./TeamFlag"; type Step = "pick" | "form" | "done"; -const MODEL_COLOR: Record = { - GPT: "var(--gpt)", - Claude: "var(--claude)", - Gemini: "var(--gemini)", -}; const MODEL_ICON: Record = { GPT: "/icons/gpt.png", Claude: "/icons/claude.jpg", Gemini: "/icons/gemini.jpeg", }; -export default function Arena() { +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export default function Arena({ + match, + predictions, + crowd: initialCrowd, + shareUrl, +}: { + match: Match; + predictions: AIPrediction[]; + crowd: CrowdStats; + shareUrl: string; +}) { const [outcome, setOutcome] = useState(null); const [scoreA, setScoreA] = useState(2); const [scoreB, setScoreB] = useState(1); const [step, setStep] = useState("pick"); - const [nickname, setNickname] = useState(""); const [email, setEmail] = useState(""); const [notify, setNotify] = useState(true); - const [crowd, setCrowd] = useState(CROWD); + const [crowd, setCrowd] = useState(initialCrowd); const [copied, setCopied] = useState(false); - // 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각) + // 투표 창: 오픈(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 finished = !!match.result; + const notOpen = !DEMO_FORCE_OPEN && now < new Date(match.opensAt).getTime(); + const locked = now >= new Date(match.lockAt).getTime(); + const disabled = finished || locked || notOpen; + const ctaLabel = finished + ? "결과 보기" + : notOpen + ? `투표 오픈 ${kickoffDisplay(match.opensAt)}` + : locked + ? "예측 마감 (경기 시작)" + : "AI보다 잘 맞히기"; const matched = useMemo(() => { if (!outcome) return []; - return AI_PREDICTIONS.filter((p) => p.outcome === outcome).map((p) => ({ - model: p.model, - exact: p.scoreA === scoreA && p.scoreB === scoreB, - })); - }, [outcome, scoreA, scoreB]); + return predictions + .filter((p) => p.outcome === outcome) + .map((p) => ({ model: p.model, exact: p.scoreA === scoreA && p.scoreB === scoreB })); + }, [outcome, scoreA, scoreB, predictions]); const submitPick = () => { if (!outcome) return; setStep("form"); }; const confirmSubmit = () => { - if (nickname.trim().length < 2) return; + if (!EMAIL_RE.test(email.trim())) return; setCrowd((c) => ({ ...c, total: c.total + 1, @@ -66,16 +82,16 @@ export default function Arena() { const shareText = useMemo(() => { if (!outcome) return ""; - const me = `${MATCH.teamA.shortName} ${scoreA}-${scoreB} ${MATCH.teamB.shortName}`; + const me = `${match.teamA.shortName} ${scoreA}-${scoreB} ${match.teamB.shortName}`; const sameAI = matched.map((m) => m.model).join("·"); return sameAI ? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick` : `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`; - }, [outcome, scoreA, scoreB, matched]); + }, [outcome, scoreA, scoreB, matched, match]); const copyLink = async () => { try { - await navigator.clipboard.writeText(`${shareText}\n${LANDING_URL}`); + await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch { @@ -85,7 +101,11 @@ export default function Arena() { const nativeShare = async () => { if (navigator.share) { try { - await navigator.share({ text: shareText, url: LANDING_URL }); + await navigator.share({ + title: `${match.teamA.shortName} vs ${match.teamB.shortName} — TriplePick`, + text: shareText, + url: shareUrl, + }); } catch { /* cancelled */ } @@ -94,57 +114,59 @@ export default function Arena() { return ( <> + {/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */} + {!finished && !notOpen && ( +
+ +
+ )} + {/* ===== 레이어드 화이트 시트 (002) ===== */} -
-
-

AI의 예측

- +
+
+

AI의 예측 대결

- {AI_PREDICTIONS.map((p) => ( -
-
- {p.model} -
-
- {p.model} + {predictions.map((p) => { + const wp = winProb(match, p); + return ( +
+
+ {p.model} +
+
+ {p.model} +
+ {/* D3: 승리 예측 팀의 승리 확률 + 승리팀 국기 */} +
+ {wp.team && ( + + )} + + {wp.label} {wp.pct}% + +
-
- Confidence -
-
-
-
+
{p.scoreA} - {p.scoreB}
-
- {p.confidencePct}% -
-
-
-
+ {/* 메인 리즌 강조 (D3: 확률은 텍스트, 바 제거) */} +

+ “{p.reasonShort}” +

-

- {p.reasonShort} -

-
- ))} + ); + })}
{/* 당신의 선택 */} @@ -158,16 +180,16 @@ export default function Arena() {
{( [ - ["TEAM_A_WIN", `${MATCH.teamA.shortName} 승`], + ["TEAM_A_WIN", `${match.teamA.shortName} 승`], ["DRAW", "무승부"], - ["TEAM_B_WIN", `${MATCH.teamB.shortName} 승`], + ["TEAM_B_WIN", `${match.teamB.shortName} 승`], ] as [Outcome, string][] ).map(([val, label]) => (
- + : - +
- {/* ===== CTA 민트 #85FCA8 ===== */} - {step === "pick" && ( + {/* ===== 종료된 경기: 결과 보기 ===== */} + {finished && match.result && ( +
+
최종 결과
+
+ {match.teamA.shortName} {match.result.scoreA}-{match.result.scoreB}{" "} + {match.teamB.shortName} + + {outcomeLabel(match, match.result.outcome)} + +
+
+ {predictions.map((p) => { + const hit = p.outcome === match.result!.outcome; + return ( +
+ {p.model} + + {hit ? "적중 ✓" : "빗나감"} + +
+ ); + })} +
+
+ )} + + {/* ===== CTA 민트 #85FCA8 (진행 중일 때) ===== */} + {!finished && step === "pick" && (
)} - {step === "done" && outcome && ( + {/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */} + {!finished && step === "done" && outcome && (
내 예측
- {MATCH.teamA.shortName} {scoreA}-{scoreB} {MATCH.teamB.shortName} + {match.teamA.shortName} {scoreA}-{scoreB} {match.teamB.shortName} - {outcomeLabel(MATCH, outcome)} + {outcomeLabel(match, outcome)}

{matched.length > 0 ? ( <> - 당신은 {matched.map((m) => m.model).join("·")}와 같은 - 선택입니다.{matched.some((m) => m.exact) ? " 스코어까지 일치! " : " "} + 당신은 {matched.map((m) => m.model).join("·")}와 같은 선택입니다. + {matched.some((m) => m.exact) ? " 스코어까지 일치! " : " "} 나머지 AI는 생각이 다릅니다. ) : ( @@ -247,7 +298,7 @@ export default function Arena() {

- NEW 정확한 스코어까지 - 맞히면 100만원 상금에 도전하세요 + 경기마다 승패·스코어를 맞혀 포인트를 쌓고, 누적 1위에게 최종 100만원

- {/* ===== Crowd Pick (003) ===== */} + {/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
-
+
Crowd Pick{" "} - - | 참여자들의 선택 - + | 참여자들의 선택 +
+
+ 참여자 투표 비율입니다 (AI 승리 확률과 다릅니다)
- + - +
{crowd.total.toLocaleString()}명 참여 @@ -348,31 +399,21 @@ function StepBtn({ } function CrowdSeg({ - flag, - cze, + team, value, tone, }: { - flag?: string; - cze?: boolean; + team?: import("@/lib/types").Team; value: number; - tone: "kor" | "draw" | "cze"; + tone: "a" | "draw" | "b"; }) { - const bg = - tone === "draw" ? "#2b313c" : tone === "kor" ? "rgba(62,224,138,0.22)" : "#222831"; + const bg = tone === "draw" ? "#2b313c" : tone === "a" ? "rgba(74,255,160,0.22)" : "#222831"; return (
- {flag && KOR} - {cze && ( - - - - - - )} + {team && } {value}%
); diff --git a/components/BallIcon.tsx b/components/BallIcon.tsx index d92a63e..e8e87a5 100644 --- a/components/BallIcon.tsx +++ b/components/BallIcon.tsx @@ -5,7 +5,7 @@ export default function BallIcon({ className = "" }: { className?: string }) { src="/icons/profile.png?v=3" alt="TriplePick" className={`rounded-full ${className}`} - style={{ filter: "drop-shadow(0 0 7px rgba(62,224,138,0.55))" }} + style={{ filter: "drop-shadow(0 0 7px rgba(74,255,160,0.55))" }} /> ); } diff --git a/components/Countdown.tsx b/components/Countdown.tsx new file mode 100644 index 0000000..de1feae --- /dev/null +++ b/components/Countdown.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; + +// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt). +// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만. +export default function Countdown({ + to, + label = "투표 종료까지", +}: { + to: string; + label?: string; +}) { + const [left, setLeft] = useState(null); + + useEffect(() => { + const target = new Date(to).getTime(); + const tick = () => setLeft(Math.max(0, target - Date.now())); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [to]); + + const ended = left !== null && left <= 0; + + return ( +
+ + {ended ? "투표가 마감되었습니다" : label} + + {!ended && ( + + {left === null ? "--:--:--" : fmt(left)} + + )} +
+ ); +} + +function fmt(ms: number): string { + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + if (d > 0) return `${d}일 ${pad(h)}:${pad(m)}:${pad(sec)}`; + return `${pad(h)}:${pad(m)}:${pad(sec)}`; +} diff --git a/components/Footer.tsx b/components/Footer.tsx index 57b083c..2c8923f 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,15 +1,15 @@ export default function Footer() { return (
-

+

Independent AI prediction event. Not official.

-

+

본 서비스는 독립적인 AI 예측 이벤트이며, 공식·조작·후원·운영과 무관합니다.

-
+

스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다. @@ -20,7 +20,7 @@ export default function Footer() {

-
+
© 2026 TriplePick · 운영 AIO2O · @triplepick_ai
diff --git a/components/Hero.tsx b/components/Hero.tsx index c4d1af4..a9575d6 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -1,8 +1,19 @@ -import { MATCH } from "@/lib/mockData"; +import Link from "next/link"; -export default function Hero() { +// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출. +export default function Hero({ back = false }: { back?: boolean }) { return ( -
+
+ {back && ( +
+ + ← 전체 일정 + +
+ )}
TRIPLE PICK 2026
@@ -10,16 +21,7 @@ export default function Hero() { AI Prediction Arena
- AI와 함께하는 월드컵 승부 예측 대결 챌린지 -
- -

- - {MATCH.hookText} - -

-
- Korea Republic vs Czechia · Matchday Prediction + AI와 함께하는 글로벌 축구 승부예측 챌린지
); diff --git a/components/MatchupHUD.tsx b/components/MatchupHUD.tsx index e9c5042..690d42a 100644 --- a/components/MatchupHUD.tsx +++ b/components/MatchupHUD.tsx @@ -1,12 +1,14 @@ -import { MATCH } from "@/lib/mockData"; +import type { Match } from "@/lib/types"; import { kickoffDisplay } from "@/lib/format"; import BallIcon from "./BallIcon"; +import TeamFlag from "./TeamFlag"; -export default function MatchupHUD() { - const { group, roundLabel } = MATCH; +export default function MatchupHUD({ match }: { match: Match }) { + const { group, roundLabel } = match; + const finished = !!match.result; return (
- {/* Match 01 → Final 진행 */} + {/* 진행 바: roundLabel → Final */}
{roundLabel} @@ -27,65 +29,52 @@ export default function MatchupHUD() { {/* 대결 카드 (녹색 글로우 보더) */}
- {/* 헤더: 축구공 아이콘 + 타이틀 + 일시 */} + {/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
- {MATCH.teamA.name}{" "} - vs {MATCH.teamB.name} + {match.teamA.name} vs{" "} + {match.teamB.name}
- {kickoffDisplay(MATCH.kickoffKst)} · Group {group} + {kickoffDisplay(match.kickoffKst)} · Group {group}
-
{MATCH.venue}
+
{match.venue}
- {/* 국기 + 라이트닝 VS */} + {/* 국기 + 라이트닝 VS (종료 시 최종 스코어) */}
- +
- - VS - + {finished && match.result ? ( + + {match.result.scoreA} + - + {match.result.scoreB} + + ) : ( + + VS + + )}
- +
+ + {finished && ( +
+ 경기 종료 · 최종 결과 +
+ )}
); } - -function KorFlag() { - // 실제 국기 이미지 - return ( - Korea Republic - ); -} - -function CzeFlag() { - // 체코 국기 (이미지 없음 → SVG) - return ( - - - - - - ); -} diff --git a/components/ScheduleBoard.tsx b/components/ScheduleBoard.tsx new file mode 100644 index 0000000..3793a19 --- /dev/null +++ b/components/ScheduleBoard.tsx @@ -0,0 +1,98 @@ +import Link from "next/link"; +import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule"; +import { getPredictions, getCrowd } from "@/lib/mockData"; +import { dateHeading, timeOnly } from "@/lib/format"; +import type { Match } from "@/lib/types"; +import TeamFlag from "./TeamFlag"; + +const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러 + +const PHASE_META: Record = { + open: { label: "투표 중", cls: "border-[#94FBE0] text-[#94FBE0]" }, + scheduled: { label: "오픈 예정", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" }, + locked: { label: "투표 마감", cls: "border-[var(--line-d)] text-[var(--ink-muted)]" }, + finished: { label: "종료", cls: "border-white/15 text-white/55" }, +}; + +export default function ScheduleBoard() { + const groups = matchesByDate(); + return ( +
+
+

전체 일정

+
+ +
+ {groups.map((g) => ( +
+
+ {dateHeading(g.date)} +
+
+ {g.matches.map((m) => ( + + ))} +
+
+ ))} +
+
+ ); +} + +function MatchCard({ match }: { match: Match }) { + const phase = matchPhase(match); + const meta = PHASE_META[phase]; + const preds = getPredictions(match); + const crowd = getCrowd(match); + + // AI 픽 갈림 요약 + const tally = { a: 0, d: 0, b: 0 }; + for (const p of preds) { + if (p.outcome === "TEAM_A_WIN") tally.a++; + else if (p.outcome === "DRAW") tally.d++; + else tally.b++; + } + const split: string[] = []; + if (tally.a) split.push(`${match.teamA.shortName} ${tally.a}`); + if (tally.d) split.push(`무 ${tally.d}`); + if (tally.b) split.push(`${match.teamB.shortName} ${tally.b}`); + + return ( + +
+ + {timeOnly(match.kickoffKst)} KST · {match.roundLabel} + + + {meta.label} + +
+ +
+
+ + {match.teamA.shortName} +
+ VS +
+ {match.teamB.shortName} + +
+
+ +
+ + AI 픽 {split.join(" · ")} + + + {crowd.total.toLocaleString()}명 참여 + + +
+ + ); +} diff --git a/components/TeamFlag.tsx b/components/TeamFlag.tsx new file mode 100644 index 0000000..5a69031 --- /dev/null +++ b/components/TeamFlag.tsx @@ -0,0 +1,46 @@ +import type { Team } from "@/lib/types"; + +// 모든 경기에 일반화된 국기 렌더. +// KOR = 실제 png 자산, CZE = 인라인 SVG(기존 디자인 유지), 그 외 = 이모지 박스. +export default function TeamFlag({ + team, + className = "", +}: { + team: Team; + className?: string; +}) { + if (team.code === "KOR") { + return ( + {team.name} + ); + } + if (team.code === "CZE") { + return ( + + + + + + ); + } + // 그 외 국가 — 이모지 국기 (자산 추가 전 fallback) + return ( + + + {team.flag} + + + ); +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 63330a1..76a7ca1 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -89,12 +89,22 @@ --- -## 8. 06.09 회의로 반영해야 할 디자인 변경 (대기) +## 8. 06.09 회의 반영 (DONE 2026-06-09) > 상세: `../../Worldcup 2026/000. Plan/Task — 랜딩·디자인·프론트엔드 업데이트 (06.09 회의).md` -- [ ] 확률 라벨 `Confidence` → **"OO 승리 확률 XX%"**(이긴 팀 기준) + 승리팀 국기 -- [ ] confidence 바 축소/삭제 검토, **근거(메인 리즌) 강조**, 모델 텍스트 짧고 재미있게 -- [ ] **닉네임 필드 제거 → 이메일만** -- [ ] 서브타이틀 `Korea Republic vs Czechia · Matchday Prediction` **삭제(중복)** -- [ ] **"투표 종료까지" 카운트다운** 상세 페이지에 복원 -- [ ] 멀티 경기: 대시보드 + 경기 상세 + (차기)리더보드 — 동일 토큰으로 확장 -- [ ] 예측하기 → (경기 후) **결과 보기** 버튼 + 최종 스코어 표시 +- [x] 확률 라벨 `Confidence` → **"OO 승리 확률 XX%"**(이긴 팀 기준) + 승리팀 국기 (`format.winProb` + `TeamFlag`) +- [x] confidence 바 **h-1로 축소**, **근거(메인 리즌) 15px 볼드 인용("…")으로 강조** +- [x] **닉네임 필드 제거 → 이메일만** + 동의 체크박스(다음 경기/100만원) +- [x] 서브타이틀 `… Matchday Prediction` **삭제(중복)** — Hero는 브랜드 헤더로 단일화 +- [x] **"투표 종료까지" 카운트다운** 상세에 복원 (`Countdown.tsx`, 마감=lockAt=킥오프) +- [x] 멀티 경기: **대시보드(`/`) + 경기 상세(`/match/[matchId]`) + 리더보드 stub(`/leaderboard`)** — 동일 토큰 +- [x] 예측하기 → (경기 후) **결과 보기** + 최종 스코어 (`match.result` 분기, MatchupHUD/Arena) +- [x] **"월드컵" 표현 제거**(D9) — Hero·schedule hookText·meta → "글로벌 축구" +- [x] **경기별 공유**(D8) — `matchUrl()` 딥링크, `navigator.share` invoke +- [x] Crowd Pick에 **"AI 승리 확률과 다릅니다"** 구분 라벨 + +## 9. 멀티 경기 구조 (06.09 신규) +- **라우팅**: `/`=대시보드(전체 일정) · `/match/[matchId]`=경기 상세(6경기 SSG) · `/leaderboard`=차기 stub +- **컴포넌트 파라미터화**: `MatchupHUD({match})` · `Arena({match, predictions, crowd, shareUrl})` — 싱글톤 의존 제거 +- **국기 일반화**: `TeamFlag({team})` — KOR png / CZE 인라인 SVG / 그 외 이모지 박스 +- **데이터**: `mockData.getPredictions(match)`/`getCrowd(match)` — 체코전 큐레이션, 그 외 `matchId` 해시 결정론 생성(Math.random 미사용) +- **대시보드 카드**(`ScheduleBoard.tsx`): 날짜 그룹 → 시간·라운드·상태칩·팀 국기·AI 픽 갈림·참여수 → 상세 딥링크. 네이버 e스포츠 승부예측 톤, 다크 토큰 유지 diff --git a/docs/SCORING.md b/docs/SCORING.md new file mode 100644 index 0000000..00b4ba1 --- /dev/null +++ b/docs/SCORING.md @@ -0,0 +1,165 @@ +# TriplePick — 참여자 채점 · 심사 설계 (SSOT) + +최종 갱신: 2026-06-09 KST +근거: `001. Meeting Minute/WorldCup_triplepick landing_2026.06.09.txt` (05:48 승패/스코어 가점, 30:36~31:37 포인트 총점·주최자 공정성, 51:56 근접/정확 배점) + 대표 확정(2026-06-09). +관련: `docs/BACKEND.md`(채점 구현), `lib/types.ts`(타입), 골드 상금 카드(`components/Arena.tsx`). + +> 이 문서는 **100만원 최종 우승자 심사의 단일 기준(SSOT)**이다. 약관·랜딩 고지·백엔드 채점은 모두 이 문서를 따른다. + +--- + +## 0. 확정 사항 (한 줄 요약) + +- **공정성 모델 = 주최측 배제 + 순수 누적 포인트.** 점수는 **정확도에서만** 발생(참여 횟수에 직접 점수 없음). +- 100만원 = **대회 통합 누적 포인트 1위 1인**에게 최종 지급(경기마다 X). +- 채점은 **내 예측 vs 실제 경기 결과**. AI(GPT·Claude·Gemini)는 재미·기준점이며 채점 대상이 아니다. + +--- + +## 1. 공정성 3원칙 + +| # | 원칙 | 이유 | +|---|---|---| +| **P1. 주최측·임직원 배제** | 운영진/주최자는 수상 대상에서 제외 | 회의 30:36 우려("주최자가 모든 경기 참여 가능 → 유리") 직접 해소. 상금 이벤트 표준 약관 | +| **P2. 점수는 정확도에서만** | 참여 횟수 자체에는 점수를 주지 않음 (틀린 픽 = 0점) | "많이 찍기" 어뷰징 무력화 — 부정확한 대량 참여는 보상이 0 | +| **P3. 누적 총점 1위** | 대회 전체 누적 포인트가 가장 높은 사람 | 대회 끝에 지급 → 1경기 운빨 우승은 누적으로 자연 추월됨. 꾸준한 정확도가 이김 | + +> **누적이 운빨을 방어하는 이유:** 단일 경기 최대 5점. 토너먼트(다수 경기) 누적에서는 성실·정확한 참여자가 반드시 앞선다. 그래서 별도 "최소 참여 경기 자격선"은 두지 않는다(불필요). + +--- + +## 2. 경기별 적중 포인트 (정확도 기반) + +예측 `pick = {scoreA, scoreB, outcome}` vs 실제 `result = {scoreA, scoreB, outcome}`. + +| 등급 | 조건 | 포인트 (기본값·**팀 확정 대상**) | +|---|---|---| +| **정확 스코어** | 양 팀 득점 **완전 일치** (승패 당연 포함) | **5** | +| **근접 (득실차)** | 승패 적중 **+ 득실차 일치** (정확 스코어 제외) | **3** | +| **승패 적중** | 승/무/패만 일치 | **2** | +| **부분 근접** | 승패는 빗나갔으나 **한 팀 득점 일치** | **1** | +| **빗나감** | 그 외 | **0** | + +- 단조 증가(정확 > 근접 > 승패 > 부분 > 빗나감)라 "더 정밀할수록 더 많은 점수"가 보장된다. +- 무승부: 승패 적중이면서 득실차(=0) 일치 → 정확이 아니면 **근접(3점)**. +- **배점 수치는 황 본부장 확정 후 dev 전달**(회의 05:48). 위는 기본 제안값. + +### 채점 알고리즘 (결정론적) + +``` +1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB → 정확(5) +2) else if pick.outcome == result.outcome: + (pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2) +3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(1) +4) else → 0 +``` + +--- + +## 3. 랭킹 (누적 총점 1위) + +- **1차 정렬: 누적 적중 포인트 합계(Σ)** — 높을수록 상위. +- **동점자 보정(tie-break, 순서대로):** + 1. 정확 스코어 적중 **횟수** (정밀성) + 2. **적중률** = 누적 포인트 ÷ 참여 경기 수 (효율) + 3. 참여 경기 수 (성실성) + 4. 최초 해당 점수 도달 **시각** (먼저 달성한 쪽) +- 동점 보정도 모두 같으면 **약관에 따라 분할 또는 추첨**. + +> 참여 경기 수·적중률은 **동점 보정용**으로만 쓴다. 1차 순위는 어디까지나 순수 누적 포인트. + +--- + +## 4. 자격 · 배제 (P1 구현) + +- **식별 단위 = 이메일**(비로그인). 정규화: 소문자화, (옵션) Gmail `.`/`+alias` 정규화로 동일인 합산. +- **배제 대상:** + - 임직원·운영진 이메일 **블록리스트**(`STAFF_EMAILS`). + - 회사 도메인 자동 제외: `@o2o.kr`, `@aio2o.*` 등(`STAFF_DOMAINS`). +- **알려진 한계 — 다중 이메일 어뷰징:** 한 사람이 여러 메일로 참여 가능. 현 단계 비로그인 정책상 완전 차단 불가 → **약관에 "1인 1계정 원칙, 위반 시 실격" 명시** + 차기 본인 인증(휴대폰/소셜)으로 보완. 의심 계정은 운영자 수동 실격 가능. + +--- + +## 5. 채점 대상 "결과"의 정의 (분쟁 방지) + +> ★ **팀 확인 필요** — 아래는 권장 기본값. + +- **정규시간(전·후반 + 추가시간) 종료 스코어** 기준. **연장·승부차기 제외**(예측은 90분 기준이 일반적). +- 경기 **취소/몰수/중단**: 해당 경기 **전원 채점 제외**(자격 분모에서도 제외). +- **무효 제출:** 마감(킥오프) 이후 제출 무효. 동일 이메일 다중 제출 시 **마감 전 마지막 1건만 유효**. +- 결과 입력 출처(공식 스코어 소스)와 정정 정책은 `BACKEND.md`에서 1개 소스로 고정. + +--- + +## 6. 투명성 (회의 30:38 "오해 없이 사전 공지") + +발행 전 **랜딩/약관에 사전 고지**: +- 배점표(2장) · 배제 대상(4장) · 동점 규칙(3장) · 결과 기준=정규시간(5장) · 지급 방식(대회 통합 1인). +- 본인 누적 포인트·현재 순위 열람(리더보드 = 차기 페이지에서). + +--- + +## 7. 참조 구현 (TypeScript, `lib/types.ts` 정합) + +```ts +import type { Outcome, UserPick } from "@/lib/types"; + +type MatchResult = { scoreA: number; scoreB: number; outcome: Outcome }; + +// 배점 — 팀 확정 후 이 상수만 교체 +export const SCORE = { EXACT: 5, CLOSE: 3, OUTCOME: 2, PARTIAL: 1, MISS: 0 } as const; + +export function scorePrediction(pick: UserPick, r: MatchResult): number { + if (pick.scoreA === r.scoreA && pick.scoreB === r.scoreB) return SCORE.EXACT; + if (pick.outcome === r.outcome) { + return pick.scoreA - pick.scoreB === r.scoreA - r.scoreB ? SCORE.CLOSE : SCORE.OUTCOME; + } + if (pick.scoreA === r.scoreA || pick.scoreB === r.scoreB) return SCORE.PARTIAL; + return SCORE.MISS; +} + +// 배제 대상 +const STAFF_EMAILS = new Set([/* "ceo@…", … */]); +const STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"]; +export function isExcluded(email: string): boolean { + const e = email.trim().toLowerCase(); + return STAFF_EMAILS.has(e) || STAFF_DOMAINS.some((d) => e.endsWith("@" + d)); +} + +// 누적 집계 → 랭킹 +export interface Standing { + email: string; + totalPoints: number; // 1차 정렬 + exactCount: number; // tie ① + matchesPlayed: number; // tie ③ (적중률 분모) + firstReachedAt: number;// tie ④ (epoch ms) +} +export function rank(standings: Standing[]): Standing[] { + return standings + .filter((s) => !isExcluded(s.email)) + .sort((a, b) => + b.totalPoints - a.totalPoints || + b.exactCount - a.exactCount || + b.totalPoints / b.matchesPlayed - a.totalPoints / a.matchesPlayed || // 적중률 tie② + b.matchesPlayed - a.matchesPlayed || + a.firstReachedAt - b.firstReachedAt, + ); +} +``` + +> `UserPick`은 `lib/types.ts`에 이미 정의됨(`{outcome, scoreA, scoreB}`). 위 함수는 백엔드(Cloud Functions) 결과 입력 시 호출되어 사용자별 누적 포인트를 갱신한다(상세 `BACKEND.md`). + +--- + +## 8. 미결정 / 팀 확정 대기 + +1. **배점 수치** — `SCORE` 5/3/2/1/0 기본값 확정 (황 본부장, 회의 05:48). 부분근접(1점) 채택 여부 포함. +2. **결과 기준** — 정규시간 90분 vs 연장 포함 (5장). 권장=정규시간. +3. **공식 스코어 소스** — 결과 입력 단일 출처 + 정정 정책(`BACKEND.md`). +4. **동률 최종 처리** — 분할 vs 추첨 (약관). +5. **랜딩 카피 정합** — 골드 상금 카드 문구를 본 모델에 맞춤(아래). + +### 골드 카드 카피 정합 (권장) +현재: "투표 참여율, 승률과 점수 정답률 점수로 최종 1등에게 증정" +→ 본 모델(참여 횟수는 점수 아님)과 맞추려면: +**"경기마다 승패·스코어를 맞혀 포인트를 쌓고, 누적 1위에게 최종 100만원"** 권장. diff --git a/lib/format.ts b/lib/format.ts index 7eab126..af9f4c5 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,4 +1,4 @@ -import type { Outcome, Match } from "./types"; +import type { Outcome, Match, AIPrediction, Team } from "./types"; export function outcomeLabel(match: Match, o: Outcome): string { if (o === "TEAM_A_WIN") return `${match.teamA.shortName} 승`; @@ -6,6 +6,40 @@ export function outcomeLabel(match: Match, o: Outcome): string { return "무승부"; } +// D3: 확률 = "이긴다고 예측한 팀의 승리 확률". 무승부 예측이면 "무승부 가능성". +export function winProb( + match: Match, + p: Pick, +): { team: Team | null; label: string; pct: number } { + if (p.outcome === "TEAM_A_WIN") + return { team: match.teamA, label: `${match.teamA.shortName} 승리 확률`, pct: p.confidencePct }; + if (p.outcome === "TEAM_B_WIN") + return { team: match.teamB, label: `${match.teamB.shortName} 승리 확률`, pct: p.confidencePct }; + return { team: null, label: "무승부 가능성", pct: p.confidencePct }; +} + +// "6월 12일 (금)" — 대시보드 날짜 그룹 헤더 +export function dateHeading(iso: string): string { + const days = ["일", "월", "화", "수", "목", "금", "토"]; + const dm = iso.match(/(\d{4})-(\d{2})-(\d{2})/); + if (!dm) return iso; + const [, y, mo, d] = dm; + const dow = days[new Date(Date.UTC(+y, +mo - 1, +d)).getUTCDay()]; + return `${+mo}월 ${+d}일 (${dow})`; +} + +// "11:00" — 경기 카드 시간 +export function timeOnly(iso: string): string { + const m = iso.match(/T(\d{2}):(\d{2})/); + return m ? `${m[1]}:${m[2]}` : ""; +} + +// "2026-06-12" — 날짜 그룹 키 +export function dateKey(iso: string): string { + const m = iso.match(/(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : iso; +} + export function kickoffDisplay(iso: string): string { // iso는 KST(+09:00). 런타임 TZ에 무관하게 ISO 문자열을 직접 파싱한다. const days = ["일", "월", "화", "수", "목", "금", "토"]; diff --git a/lib/mockData.ts b/lib/mockData.ts index f6a801c..c7806c0 100644 --- a/lib/mockData.ts +++ b/lib/mockData.ts @@ -1,8 +1,11 @@ // 1차 mock 데이터 — Firebase 연결 전까지 사용. // ⚠️ AI 예측은 플레이스홀더. 발행 전 GPT/Claude/Gemini 실제 출력(JSON)으로 교체할 것. // 경기 일정은 lib/schedule.ts(Group A 정식 일정, KST)에서 자동 반영. -import type { AIPrediction, CrowdStats } from "./types"; -import { FEATURED } from "./schedule"; +// +// 멀티 경기(06.09 회의): 모든 경기에 3 AI 예측 + Crowd 가 있어야 대시보드/상세가 깨지지 않는다. +// 체코전(FEATURED)은 큐레이션 값, 그 외 경기는 matchId 시드 결정론적 생성 (Math.random 미사용). +import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./types"; +import { FEATURED, GROUP_A } from "./schedule"; export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT) @@ -10,7 +13,8 @@ export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSO // true면 오픈 게이트를 무시하고 즉시 참여 가능. 운영 배포 시 false 로. export const DEMO_FORCE_OPEN = true; -export const AI_PREDICTIONS: AIPrediction[] = [ +// 체코전 큐레이션 예측 (D3: reason 짧고 또렷이) +const FEATURED_PREDICTIONS: AIPrediction[] = [ { matchId: MATCH.matchId, model: "GPT", @@ -18,7 +22,7 @@ export const AI_PREDICTIONS: AIPrediction[] = [ scoreA: 2, scoreB: 1, confidencePct: 62, - reasonShort: "한국의 측면 공격 우위와 세트피스 강점", + reasonShort: "한국 측면 스피드가 한 끗 위", generatedAt: "2026-06-08", }, { @@ -28,7 +32,7 @@ export const AI_PREDICTIONS: AIPrediction[] = [ scoreA: 1, scoreB: 1, confidencePct: 41, - reasonShort: "중원 밸런스가 팽팽한 접전 예상", + reasonShort: "중원 힘싸움이 끝까지 팽팽", generatedAt: "2026-06-08", }, { @@ -38,17 +42,111 @@ export const AI_PREDICTIONS: AIPrediction[] = [ scoreA: 0, scoreB: 2, confidencePct: 28, - reasonShort: "체코의 전환 속도와 결정력 우세", + reasonShort: "체코 역습 한 방이 매섭다", generatedAt: "2026-06-08", }, ]; -export const CROWD: CrowdStats = { - matchId: MATCH.matchId, - total: 1284, - teamAWin: 616, // 48% - draw: 347, // 27% - teamBWin: 321, // 25% +// ── 결정론적 생성 (matchId 시드) ────────────────────────────── +function hash(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +const MODELS: ModelName[] = ["GPT", "Claude", "Gemini"]; + +// AI 셋이 갈리도록 만든 결과 조합 (홈/원정 균형) +const OUTCOME_SETS: Outcome[][] = [ + ["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"], + ["TEAM_A_WIN", "TEAM_A_WIN", "DRAW"], + ["TEAM_B_WIN", "DRAW", "TEAM_A_WIN"], + ["TEAM_A_WIN", "DRAW", "TEAM_A_WIN"], + ["DRAW", "TEAM_B_WIN", "TEAM_A_WIN"], +]; + +const SCORE_BY_OUTCOME: Record = { + TEAM_A_WIN: [[2, 1], [1, 0], [2, 0], [3, 1]], + DRAW: [[1, 1], [0, 0], [2, 2]], + TEAM_B_WIN: [[0, 1], [1, 2], [0, 2], [1, 3]], }; -export const LANDING_URL = "https://triplepick.ai/predict/korea-vs-czechia"; +function reasonFor(match: Match, outcome: Outcome, seed: number): string { + const A = match.teamA.shortName; + const B = match.teamB.shortName; + const win = outcome === "TEAM_A_WIN" ? A : outcome === "TEAM_B_WIN" ? B : null; + const winPool = win + ? [ + `${win} 측면 스피드가 한 끗 위`, + `${win} 세트피스 한 방이면 끝`, + `${win} 초반 기세에서 앞선다`, + `${win} 결정력이 승부를 가른다`, + ] + : []; + const drawPool = [ + "중원 힘싸움이 끝까지 팽팽", + "양 팀 다 한 방 있는 진검승부", + "first goal이 모든 걸 가른다", + ]; + const pool = win ? winPool : drawPool; + return pool[seed % pool.length]; +} + +function generatePredictions(match: Match): AIPrediction[] { + const base = hash(match.matchId); + const outcomes = OUTCOME_SETS[base % OUTCOME_SETS.length]; + return MODELS.map((model, i) => { + const seed = hash(match.matchId + model); + const outcome = outcomes[i]; + const scores = SCORE_BY_OUTCOME[outcome]; + const [scoreA, scoreB] = scores[seed % scores.length]; + const confidencePct = + outcome === "DRAW" ? 33 + (seed % 13) : 46 + (seed % 25); // draw 33~45, win 46~70 + return { + matchId: match.matchId, + model, + outcome, + scoreA, + scoreB, + confidencePct, + reasonShort: reasonFor(match, outcome, seed), + generatedAt: "2026-06-08", + }; + }); +} + +export function getPredictions(match: Match): AIPrediction[] { + if (match.matchId === FEATURED.matchId) return FEATURED_PREDICTIONS; + return generatePredictions(match); +} + +export function getCrowd(match: Match): CrowdStats { + if (match.matchId === FEATURED.matchId) { + return { matchId: MATCH.matchId, total: 1284, teamAWin: 616, draw: 347, teamBWin: 321 }; + } + const base = hash(match.matchId + "crowd"); + const total = 240 + (base % 1400); + const a = 28 + (base % 40); // 28~67% + const d = 18 + ((base >> 3) % 22); // 18~39% + const teamAWin = Math.round((total * a) / 100); + const draw = Math.round((total * Math.min(d, 100 - a - 5)) / 100); + const teamBWin = total - teamAWin - draw; + return { matchId: match.matchId, total, teamAWin, draw, teamBWin }; +} + +// 경기별 공유 딥링크 (D8) +const ORIGIN = "https://triplepick-web.vercel.app"; +export function matchUrl(matchId: string): string { + return `${ORIGIN}/match/${matchId}`; +} + +// ── 하위 호환 (체코전 단일 참조용) ── +export const AI_PREDICTIONS: AIPrediction[] = FEATURED_PREDICTIONS; +export const CROWD: CrowdStats = getCrowd(FEATURED); +export const LANDING_URL = matchUrl(FEATURED.matchId); + +// schedule re-export (페이지에서 한 곳에서 import) +export { GROUP_A }; diff --git a/lib/schedule.ts b/lib/schedule.ts index 28c990a..9bdbc67 100644 --- a/lib/schedule.ts +++ b/lib/schedule.ts @@ -25,7 +25,7 @@ export const GROUP_A: Match[] = [ opensAt: opens("2026-06-12T04:00:00+09:00"), lockAt: "2026-06-12T04:00:00+09:00", status: "upcoming", - hookText: "월드컵 개막전, AI는 개최국을 믿을까", + hookText: "글로벌 축구 개막전, AI는 개최국을 믿을까", result: null, }, { @@ -100,7 +100,7 @@ export const GROUP_A: Match[] = [ }, ]; -// 현재 노출할 대표 경기 +// 현재 노출할 대표 경기 (체코전) export const FEATURED_MATCH_ID = "A_KOR_CZE_20260612"; export function getMatch(id: string): Match { @@ -108,3 +108,28 @@ export function getMatch(id: string): Match { } export const FEATURED = getMatch(FEATURED_MATCH_ID); + +// 투표창 단계(D7): 오픈 전 / 투표중 / 마감(킥오프) / 종료(결과). DEMO_FORCE_OPEN는 UI 측에서 별도 처리. +export type MatchPhase = "scheduled" | "open" | "locked" | "finished"; + +export function matchPhase(m: Match, now: number = Date.now()): MatchPhase { + if (m.result) return "finished"; + if (now >= new Date(m.lockAt).getTime()) return "locked"; + if (now < new Date(m.opensAt).getTime()) return "scheduled"; + return "open"; +} + +// 대시보드: 날짜별로 묶고 킥오프 오름차순 정렬 +export function matchesByDate(): { date: string; matches: Match[] }[] { + const sorted = [...GROUP_A].sort( + (a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(), + ); + const groups: { date: string; matches: Match[] }[] = []; + for (const m of sorted) { + const key = m.kickoffKst.slice(0, 10); + const last = groups[groups.length - 1]; + if (last && last.date === key) last.matches.push(m); + else groups.push({ date: key, matches: [m] }); + } + return groups; +}