diff --git a/.env.example b/.env.example
index 88e1035..85daabc 100644
--- a/.env.example
+++ b/.env.example
@@ -21,3 +21,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
# Only needed if you use service role in Route Handlers or Server Actions.
# NEVER expose this key to the browser or prefix with NEXT_PUBLIC_.
# SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
+
+# --- OpenAI (server-only) -----------------------------------
+# Find this in: https://platform.openai.com/ → API Keys
+# NEVER expose this key to the browser or prefix with NEXT_PUBLIC_.
+OPENAI_API_KEY=sk-...
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c812053..f5f2cd6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,24 +7,9 @@ on:
branches: [main]
jobs:
- security:
- name: Secret Scanning
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Scan for secrets
- uses: gitleaks/gitleaks-action@v2
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
quality:
name: Lint, Build & Test
runs-on: ubuntu-latest
- needs: [security]
steps:
- name: Checkout
uses: actions/checkout@v4
diff --git a/CLAUDE.md b/CLAUDE.md
index acd2660..e6184cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,8 @@
# hackathon-starter Development Guidelines
## Active Technologies
+- TypeScript 5.7, Node.js (Next.js runtime) + Next.js 15, `openai` SDK, `@supabase/supabase-js` v2, `@supabase/ssr` v0.5 (001-hirameki-battle)
+- Supabase PostgreSQL — `game_scores` table (see data-model.md) (001-hirameki-battle)
- TypeScript 5.3+ + Next.js 15 (App Router), @supabase/supabase-js v2, @supabase/ssr v0.1+, Biome v1.8+, Vitest v1+
@@ -61,3 +63,6 @@ git commit -m "chore: sync template improvements from "
See CONTRIBUTING.md for the full workflow.
+
+## Recent Changes
+- 001-hirameki-battle: Added TypeScript 5.7, Node.js (Next.js runtime) + Next.js 15, `openai` SDK, `@supabase/supabase-js` v2, `@supabase/ssr` v0.5
diff --git a/app/(auth)/callback/route.ts b/app/(auth)/callback/route.ts
index f2c1029..937b8ab 100644
--- a/app/(auth)/callback/route.ts
+++ b/app/(auth)/callback/route.ts
@@ -1,23 +1,23 @@
-import { createClient } from "@/lib/supabase/server"
-import { NextResponse } from "next/server"
+import { createClient } from "@/lib/supabase/server";
+import { NextResponse } from "next/server";
/**
* Handles OAuth provider callbacks and magic link confirmations.
* Supabase redirects here after the user authenticates externally.
*/
export async function GET(request: Request) {
- const { searchParams, origin } = new URL(request.url)
- const code = searchParams.get("code")
+ const { searchParams, origin } = new URL(request.url);
+ const code = searchParams.get("code");
if (code) {
- const supabase = await createClient()
- const { error } = await supabase.auth.exchangeCodeForSession(code)
+ const supabase = await createClient();
+ const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
- return NextResponse.redirect(`${origin}/`)
+ return NextResponse.redirect(`${origin}/`);
}
}
// Return to an error page if the code exchange fails or no code is present
- return NextResponse.redirect(`${origin}/auth/error`)
+ return NextResponse.redirect(`${origin}/auth/error`);
}
diff --git a/app/api/generate-topic/route.ts b/app/api/generate-topic/route.ts
new file mode 100644
index 0000000..5769202
--- /dev/null
+++ b/app/api/generate-topic/route.ts
@@ -0,0 +1,44 @@
+import { getOpenAIClient } from "@/lib/openai";
+import OpenAI from "openai";
+import { NextResponse } from "next/server";
+
+export async function POST() {
+ try {
+ const completion = await getOpenAIClient().chat.completions.create({
+ model: "gpt-4o",
+ max_tokens: 128,
+ messages: [
+ {
+ role: "user",
+ content:
+ "日本語で、日常的なモノや状況を対象にした「○○の新しい使い方・活用法」形式のお題を1つ生成してください。お題のテキストだけを返してください。例:「傘の新しい使い方」",
+ },
+ ],
+ });
+
+ const text = completion.choices[0]?.message?.content?.trim();
+ if (!text) {
+ return NextResponse.json({ error: "予期しないエラーが発生しました。" }, { status: 500 });
+ }
+
+ return NextResponse.json({ topic: text });
+ } catch (err) {
+ if (err instanceof OpenAI.RateLimitError) {
+ return NextResponse.json(
+ {
+ error: "リクエストが多すぎます。しばらく待ってから再試行してください。",
+ retryAfter: 60,
+ },
+ { status: 429 },
+ );
+ }
+ if (err instanceof OpenAI.APIConnectionError) {
+ return NextResponse.json(
+ { error: "トピック生成サービスが一時的に利用できません。再試行してください。" },
+ { status: 503 },
+ );
+ }
+ console.error("[generate-topic] Unexpected error:", err);
+ return NextResponse.json({ error: "予期しないエラーが発生しました。" }, { status: 500 });
+ }
+}
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
index ae4436f..4f4d29e 100644
--- a/app/api/health/route.ts
+++ b/app/api/health/route.ts
@@ -1,5 +1,5 @@
-import { createClient } from "@/lib/supabase/server"
-import { NextResponse } from "next/server"
+import { createClient } from "@/lib/supabase/server";
+import { NextResponse } from "next/server";
/**
* GET /api/health
@@ -8,26 +8,26 @@ import { NextResponse } from "next/server"
*/
export async function GET() {
try {
- const supabase = await createClient()
- const { error } = await supabase.from("demo_items").select("id").limit(1)
+ const supabase = await createClient();
+ const { error } = await supabase.from("demo_items").select("id").limit(1);
if (error) {
- console.error("[health] DB query error:", error.message)
+ console.error("[health] DB query error:", error.message);
return NextResponse.json(
{ status: "error", message: "Database connection failed" },
{ status: 503 },
- )
+ );
}
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
- })
+ });
} catch (err) {
- console.error("[health] Unexpected error:", err)
+ console.error("[health] Unexpected error:", err);
return NextResponse.json(
{ status: "error", message: "Internal server error" },
{ status: 500 },
- )
+ );
}
}
diff --git a/app/api/score-idea/route.ts b/app/api/score-idea/route.ts
new file mode 100644
index 0000000..e55624d
--- /dev/null
+++ b/app/api/score-idea/route.ts
@@ -0,0 +1,131 @@
+import { getOpenAIClient } from "@/lib/openai";
+import { insertGameScore } from "@/lib/game-scores";
+import OpenAI from "openai";
+import { type NextRequest, NextResponse } from "next/server";
+
+const SCORE_FUNCTION: OpenAI.Chat.Completions.ChatCompletionTool = {
+ type: "function",
+ function: {
+ name: "submit_score",
+ description: "プレイヤーのアイデアを3軸で採点し、一言コメントを返す",
+ parameters: {
+ type: "object",
+ properties: {
+ originality: {
+ type: "number",
+ description: "独創性スコア(0〜33点):既成概念にとらわれない独自性",
+ },
+ practicality: {
+ type: "number",
+ description: "実用性スコア(0〜33点):実際に使えそうか・現実的か",
+ },
+ unexpectedness: {
+ type: "number",
+ description: "意外性スコア(0〜34点):予想外・驚きがあるか",
+ },
+ comment: {
+ type: "string",
+ description: "採点理由・講評を含む一言コメント(日本語)",
+ },
+ },
+ required: ["originality", "practicality", "unexpectedness", "comment"],
+ },
+ },
+};
+
+export async function POST(request: NextRequest) {
+ let body: { sessionId?: string; topic?: string; idea?: string };
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: "無効なリクエスト形式です。" }, { status: 400 });
+ }
+
+ const { sessionId, topic, idea } = body;
+
+ if (!sessionId || !topic) {
+ return NextResponse.json({ error: "無効なリクエスト形式です。" }, { status: 400 });
+ }
+ if (!idea || idea.trim().length === 0) {
+ return NextResponse.json({ error: "アイデアを入力してください。" }, { status: 400 });
+ }
+ if (idea.length > 500) {
+ return NextResponse.json(
+ { error: "アイデアは500文字以内で入力してください。" },
+ { status: 400 },
+ );
+ }
+
+ try {
+ const completion = await getOpenAIClient().chat.completions.create({
+ model: "gpt-4o",
+ max_tokens: 512,
+ tools: [SCORE_FUNCTION],
+ tool_choice: { type: "function", function: { name: "submit_score" } },
+ messages: [
+ {
+ role: "user",
+ content: `お題:「${topic}」\nプレイヤーのアイデア:「${idea}」\n\nこのアイデアを独創性・実用性・意外性の3軸で採点し、一言コメントを付けてください。`,
+ },
+ ],
+ });
+
+ const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
+ if (!toolCall || toolCall.type !== "function") {
+ return NextResponse.json(
+ { error: "採点に失敗しました。再試行してください。" },
+ { status: 500 },
+ );
+ }
+
+ const scores = JSON.parse(toolCall.function.arguments) as {
+ originality: number;
+ practicality: number;
+ unexpectedness: number;
+ comment: string;
+ };
+
+ const totalScore = scores.originality + scores.practicality + scores.unexpectedness;
+
+ const saved = await insertGameScore({
+ sessionId,
+ topic,
+ userIdea: idea,
+ scoreOriginality: scores.originality,
+ scorePracticality: scores.practicality,
+ scoreUnexpectedness: scores.unexpectedness,
+ totalScore,
+ aiComment: scores.comment,
+ });
+
+ return NextResponse.json({
+ scoreId: saved.id,
+ originality: scores.originality,
+ practicality: scores.practicality,
+ unexpectedness: scores.unexpectedness,
+ totalScore,
+ comment: scores.comment,
+ });
+ } catch (err) {
+ if (err instanceof OpenAI.RateLimitError) {
+ return NextResponse.json(
+ {
+ error: "リクエストが多すぎます。しばらく待ってから再試行してください。",
+ retryAfter: 60,
+ },
+ { status: 429 },
+ );
+ }
+ if (err instanceof OpenAI.APIConnectionError) {
+ return NextResponse.json(
+ { error: "採点サービスが一時的に利用できません。入力内容は保持されています。" },
+ { status: 503 },
+ );
+ }
+ console.error("[score-idea] Unexpected error:", err);
+ return NextResponse.json(
+ { error: "採点に失敗しました。再試行してください。" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/api/scores/route.ts b/app/api/scores/route.ts
new file mode 100644
index 0000000..4d174a2
--- /dev/null
+++ b/app/api/scores/route.ts
@@ -0,0 +1,40 @@
+import { getGamesPlayed, getPersonalBest, getRecentScores } from "@/lib/game-scores";
+import type { GameScore } from "@/types/database";
+import type { ScoreHistoryItem } from "@/types/game";
+import { type NextRequest, NextResponse } from "next/server";
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const sessionId = searchParams.get("sessionId");
+ const limitParam = searchParams.get("limit");
+
+ if (!sessionId) {
+ return NextResponse.json({ error: "sessionIdは必須です。" }, { status: 400 });
+ }
+
+ const limit = limitParam ? Math.min(Number(limitParam), 100) : 10;
+
+ try {
+ const [rawScores, personalBest, gamesPlayed] = await Promise.all([
+ getRecentScores(sessionId, limit),
+ getPersonalBest(sessionId),
+ getGamesPlayed(sessionId),
+ ]);
+
+ const scores: ScoreHistoryItem[] = rawScores.map((s: GameScore) => ({
+ id: s.id,
+ topic: s.topic,
+ totalScore: s.total_score,
+ originality: s.score_originality,
+ practicality: s.score_practicality,
+ unexpectedness: s.score_unexpectedness,
+ comment: s.ai_comment,
+ playedAt: s.played_at,
+ }));
+
+ return NextResponse.json({ scores, personalBest, gamesPlayed });
+ } catch (err) {
+ console.error("[scores] Error fetching scores:", err);
+ return NextResponse.json({ error: "スコアの取得に失敗しました。" }, { status: 500 });
+ }
+}
diff --git a/app/error.tsx b/app/error.tsx
index 6c1ad4a..3a3ffc4 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -1,10 +1,10 @@
-"use client"
+"use client";
-import { useEffect } from "react"
+import { useEffect } from "react";
interface ErrorProps {
- error: Error & { digest?: string }
- reset: () => void
+ error: Error & { digest?: string };
+ reset: () => void;
}
/**
@@ -13,8 +13,8 @@ interface ErrorProps {
*/
export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
- console.error("[Error boundary]", error)
- }, [error])
+ console.error("[Error boundary]", error);
+ }, [error]);
return (
- )
+ );
}
diff --git a/app/game/page.tsx b/app/game/page.tsx
new file mode 100644
index 0000000..ca1873b
--- /dev/null
+++ b/app/game/page.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { GameTimer } from "@/components/features/game/GameTimer";
+import { IdeaInput } from "@/components/features/game/IdeaInput";
+import { TopicDisplay } from "@/components/features/game/TopicDisplay";
+import { getSessionId } from "@/lib/session";
+import type { GameResult, ScoreResponse, TopicResponse } from "@/types/game";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+type GamePhase = "loading" | "playing" | "submitting" | "timeout" | "error";
+
+export default function GamePage() {
+ const router = useRouter();
+ const [phase, setPhase] = useState("loading");
+ const [topic, setTopic] = useState("");
+ const [topicError, setTopicError] = useState("");
+ const [submitError, setSubmitError] = useState("");
+ const [lastIdea, setLastIdea] = useState("");
+ const timerPaused = phase !== "playing";
+
+ const fetchTopic = useCallback(async () => {
+ setPhase("loading");
+ setTopicError("");
+ try {
+ const res = await fetch("/api/generate-topic", { method: "POST" });
+ const data: TopicResponse | { error: string } = await res.json();
+ if (!res.ok || "error" in data) {
+ setTopicError("error" in data ? data.error : "お題の取得に失敗しました");
+ setPhase("error");
+ return;
+ }
+ setTopic((data as TopicResponse).topic);
+ setPhase("playing");
+ } catch {
+ setTopicError("お題の取得に失敗しました");
+ setPhase("error");
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchTopic();
+ }, [fetchTopic]);
+
+ const handleExpire = useCallback(() => {
+ setPhase("timeout");
+ }, []);
+
+ const handleTick = useCallback((_remaining: number) => {}, []);
+
+ const handleSubmit = useCallback(
+ async (idea: string) => {
+ setLastIdea(idea);
+ setSubmitError("");
+ setPhase("submitting");
+
+ let sessionId: string;
+ try {
+ sessionId = getSessionId();
+ } catch {
+ setSubmitError("セッションの取得に失敗しました。再試行してください。");
+ setPhase("playing");
+ return;
+ }
+
+ try {
+ const res = await fetch("/api/score-idea", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionId, topic, idea }),
+ });
+ const data: ScoreResponse | { error: string } = await res.json();
+
+ if (!res.ok || "error" in data) {
+ setSubmitError("error" in data ? data.error : "採点に失敗しました。再試行してください。");
+ setPhase("playing");
+ return;
+ }
+
+ const score = data as ScoreResponse;
+ const result: GameResult = {
+ topic,
+ idea,
+ scoreId: score.scoreId,
+ originality: score.originality,
+ practicality: score.practicality,
+ unexpectedness: score.unexpectedness,
+ totalScore: score.totalScore,
+ comment: score.comment,
+ playedAt: new Date().toISOString(),
+ };
+ sessionStorage.setItem("game_result", JSON.stringify(result));
+ router.push("/result");
+ } catch {
+ setSubmitError("採点に失敗しました。再試行してください。");
+ setPhase("playing");
+ }
+ },
+ [topic, router],
+ );
+
+ return (
+
+ ⚡ 閃き対決
+
+
+
+ {phase === "error" && (
+
+
{topicError || "お題の取得に失敗しました"}
+
+
+ )}
+
+ {(phase === "playing" || phase === "submitting") && (
+
+ )}
+
+ {phase === "timeout" && (
+
+
+ タイムアップ!
+
+
+
+ )}
+
+ {(phase === "playing" || phase === "submitting") && (
+
+ {submitError &&
{submitError}
}
+
+
+ )}
+
+ );
+}
diff --git a/app/history/page.tsx b/app/history/page.tsx
new file mode 100644
index 0000000..a03a39b
--- /dev/null
+++ b/app/history/page.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { ScoreList } from "@/components/features/history/ScoreList";
+import { getSessionId } from "@/lib/session";
+import type { ScoreHistoryResponse } from "@/types/game";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+
+export default function HistoryPage() {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const sessionId = getSessionId();
+ const res = await fetch(`/api/scores?sessionId=${sessionId}`);
+ if (!res.ok) {
+ setError("スコアの取得に失敗しました。");
+ return;
+ }
+ const json: ScoreHistoryResponse = await res.json();
+ setData(json);
+ } catch {
+ setError("スコアの取得に失敗しました。");
+ } finally {
+ setLoading(false);
+ }
+ }
+ load();
+ }, []);
+
+ return (
+
+
+
+ {data && (
+
+
+
{data.gamesPlayed}
+
プレイ回数
+
+
+
+ {data.personalBest ?? "-"}
+
+
自己ベスト
+
+
+ )}
+
+
+ {loading &&
読み込み中…
}
+ {error &&
{error}
}
+ {data &&
}
+
+
+
+ 新しくプレイ
+
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 1dc9946..b961614 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,20 +1,20 @@
-import type { Metadata } from "next"
-import "./globals.css"
+import type { Metadata } from "next";
+import "./globals.css";
export const metadata: Metadata = {
title: "Hackathon Starter",
description:
"A Next.js + Supabase template for rapid hackathon prototyping. Replace this with your project description.",
-}
+};
export default function RootLayout({
children,
}: Readonly<{
- children: React.ReactNode
+ children: React.ReactNode;
}>) {
return (
{children}
- )
+ );
}
diff --git a/app/not-found.tsx b/app/not-found.tsx
index b51fcc2..8570e6e 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,4 +1,4 @@
-import Link from "next/link"
+import Link from "next/link";
/**
* 404 — page not found handler.
@@ -34,5 +34,5 @@ export default function NotFound() {
Back to home
- )
+ );
}
diff --git a/app/page.tsx b/app/page.tsx
index 1ee1660..569ad84 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,24 +1,4 @@
-import { DatabaseStatus } from "@/components/features/DatabaseStatus"
-import { createClient } from "@/lib/supabase/server"
-import type { DemoItem } from "@/types"
-import { Suspense } from "react"
-
-async function fetchDemoItems(): Promise<{ items: DemoItem[] | null; error: string | null }> {
- try {
- const supabase = await createClient()
- const { data, error } = await supabase.from("demo_items").select("*").order("id")
-
- if (error) return { items: null, error: "Database connection failed" }
- return { items: data as DemoItem[], error: null }
- } catch {
- return { items: null, error: "Failed to connect to database" }
- }
-}
-
-async function HomeContent() {
- const { items, error } = await fetchDemoItems()
- return
-}
+import Link from "next/link";
export default function Home() {
return (
@@ -36,27 +16,53 @@ export default function Home() {
}}
>
- 🚀 Hackathon Starter
-
- Next.js 15 + Supabase template — replace this page with your project
+
⚡ 閃き対決
+
+ AIのお題に60秒でアイデアを出し、採点してもらおう!
-
-
- Checking database connection…
-
- }
+
+ スコア履歴
+
-
-
- )
+ );
}
diff --git a/app/result/page.tsx b/app/result/page.tsx
new file mode 100644
index 0000000..00ed51d
--- /dev/null
+++ b/app/result/page.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { AiComment } from "@/components/features/result/AiComment";
+import { ScoreBreakdown } from "@/components/features/result/ScoreBreakdown";
+import type { GameResult } from "@/types/game";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+
+export default function ResultPage() {
+ const router = useRouter();
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ try {
+ const raw = sessionStorage.getItem("game_result");
+ if (!raw) {
+ setError(true);
+ return;
+ }
+ setResult(JSON.parse(raw) as GameResult);
+ } catch {
+ setError(true);
+ }
+ }, []);
+
+ const handleReplay = () => {
+ sessionStorage.removeItem("game_result");
+ router.push("/game");
+ };
+
+ if (error) {
+ return (
+
+ 結果が見つかりませんでした。
+ ゲームを始める
+
+ );
+ }
+
+ if (!result) return null;
+
+ return (
+
+ ⚡ 採点結果
+
+
+
お題:{result.topic}
+
あなたのアイデア:{result.idea}
+
+
+
+
+
+
+
+
+
+
+
+ スコアを見る
+
+
+
+ );
+}
diff --git a/biome.json b/biome.json
index 3c4c67f..e11b2f7 100644
--- a/biome.json
+++ b/biome.json
@@ -22,12 +22,7 @@
}
},
"files": {
- "ignore": [
- "node_modules",
- ".next",
- "coverage",
- "specs"
- ]
+ "ignore": ["node_modules", ".next", "coverage", "specs"]
},
"javascript": {
"globals": ["React"]
diff --git a/bun.lock b/bun.lock
index d065b4d..9cb7653 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,6 +8,7 @@
"@supabase/ssr": "^0.5.0",
"@supabase/supabase-js": "^2.49.0",
"next": "^15.2.0",
+ "openai": "^6.32.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
},
@@ -546,6 +547,8 @@
"nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
+ "openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
+
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
diff --git a/components/features/DatabaseStatus.tsx b/components/features/DatabaseStatus.tsx
index 5aa749e..a1230d1 100644
--- a/components/features/DatabaseStatus.tsx
+++ b/components/features/DatabaseStatus.tsx
@@ -1,10 +1,10 @@
-"use client"
+"use client";
-import type { DemoItem } from "@/types"
+import type { DemoItem } from "@/types";
interface DatabaseStatusProps {
- items: DemoItem[] | null
- error: string | null
+ items: DemoItem[] | null;
+ error: string | null;
}
/**
@@ -29,7 +29,7 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) {
Supabase project is not paused.
- )
+ );
}
if (!items || items.length === 0) {
@@ -47,7 +47,7 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) {
Run supabase/seed.sql in your Supabase dashboard to add demo rows.
- )
+ );
}
return (
@@ -58,9 +58,7 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) {
borderRadius: "0.5rem",
}}
>
-
- ✓ Connected to Supabase
-
+ ✓ Connected to Supabase
{items.map((item) => (
-
@@ -69,5 +67,5 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) {
))}
- )
+ );
}
diff --git a/components/features/game/GameTimer.tsx b/components/features/game/GameTimer.tsx
new file mode 100644
index 0000000..9ab4c24
--- /dev/null
+++ b/components/features/game/GameTimer.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+interface GameTimerProps {
+ isPaused: boolean;
+ onExpire: () => void;
+ onTick: (remaining: number) => void;
+}
+
+export function GameTimer({ isPaused, onExpire, onTick }: GameTimerProps) {
+ const [remaining, setRemaining] = useState(60);
+ const onExpireRef = useRef(onExpire);
+ const onTickRef = useRef(onTick);
+
+ useEffect(() => {
+ onExpireRef.current = onExpire;
+ }, [onExpire]);
+
+ useEffect(() => {
+ onTickRef.current = onTick;
+ }, [onTick]);
+
+ useEffect(() => {
+ if (isPaused || remaining === 0) return;
+
+ const id = setInterval(() => {
+ setRemaining((prev) => {
+ const next = prev - 1;
+ onTickRef.current(next);
+ if (next === 0) {
+ onExpireRef.current();
+ }
+ return next;
+ });
+ }, 1000);
+
+ return () => clearInterval(id);
+ }, [isPaused, remaining]);
+
+ const isExpired = remaining === 0;
+ const isUrgent = remaining <= 10 && !isExpired;
+
+ return (
+
+ {isExpired ? "時間切れ!" : `${remaining}秒`}
+
+ );
+}
diff --git a/components/features/game/IdeaInput.tsx b/components/features/game/IdeaInput.tsx
new file mode 100644
index 0000000..0b18be4
--- /dev/null
+++ b/components/features/game/IdeaInput.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useState } from "react";
+
+interface IdeaInputProps {
+ isSubmitting: boolean;
+ isDisabled: boolean;
+ onSubmit: (idea: string) => void;
+}
+
+export function IdeaInput({ isSubmitting, isDisabled, onSubmit }: IdeaInputProps) {
+ const [value, setValue] = useState("");
+ const MAX = 500;
+ const remaining = MAX - value.length;
+ const isNearLimit = value.length >= 450;
+
+ const handleSubmit = () => {
+ const trimmed = value.trim();
+ if (!trimmed) return;
+ onSubmit(trimmed);
+ };
+
+ return (
+
+ );
+}
diff --git a/components/features/game/TopicDisplay.tsx b/components/features/game/TopicDisplay.tsx
new file mode 100644
index 0000000..585e198
--- /dev/null
+++ b/components/features/game/TopicDisplay.tsx
@@ -0,0 +1,24 @@
+interface TopicDisplayProps {
+ topic: string;
+ isLoading: boolean;
+}
+
+export function TopicDisplay({ topic, isLoading }: TopicDisplayProps) {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+ お題:{topic}
+ );
+}
diff --git a/components/features/history/ScoreList.tsx b/components/features/history/ScoreList.tsx
new file mode 100644
index 0000000..c80d723
--- /dev/null
+++ b/components/features/history/ScoreList.tsx
@@ -0,0 +1,67 @@
+import type { ScoreHistoryItem } from "@/types/game";
+
+interface ScoreListProps {
+ scores: ScoreHistoryItem[];
+ personalBest: number | null;
+}
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleString("ja-JP", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export function ScoreList({ scores, personalBest }: ScoreListProps) {
+ if (scores.length === 0) {
+ return まだ記録がありません
;
+ }
+
+ return (
+
+ );
+}
diff --git a/components/features/result/AiComment.tsx b/components/features/result/AiComment.tsx
new file mode 100644
index 0000000..054e663
--- /dev/null
+++ b/components/features/result/AiComment.tsx
@@ -0,0 +1,19 @@
+interface AiCommentProps {
+ comment: string;
+}
+
+export function AiComment({ comment }: AiCommentProps) {
+ return (
+
+ {comment}
+
+ );
+}
diff --git a/components/features/result/ScoreBreakdown.tsx b/components/features/result/ScoreBreakdown.tsx
new file mode 100644
index 0000000..14e2da1
--- /dev/null
+++ b/components/features/result/ScoreBreakdown.tsx
@@ -0,0 +1,59 @@
+interface ScoreBreakdownProps {
+ originality: number;
+ practicality: number;
+ unexpectedness: number;
+ totalScore: number;
+}
+
+function ScoreBar({ label, score, max }: { label: string; score: number; max: number }) {
+ const pct = Math.round((score / max) * 100);
+ return (
+
+
+ {label}
+
+ {score}/{max}点
+
+
+
+
+ );
+}
+
+export function ScoreBreakdown({
+ originality,
+ practicality,
+ unexpectedness,
+ totalScore,
+}: ScoreBreakdownProps) {
+ return (
+
+
+
+
+
+ 合計:
+ {totalScore}
+ /100点
+
+
+ );
+}
diff --git a/lib/env.ts b/lib/env.ts
index 042eef5..73e21f9 100644
--- a/lib/env.ts
+++ b/lib/env.ts
@@ -4,10 +4,7 @@
* so developers see a clear error rather than a cryptic runtime failure.
*/
-const required = [
- "NEXT_PUBLIC_SUPABASE_URL",
- "NEXT_PUBLIC_SUPABASE_ANON_KEY",
-] as const
+const required = ["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"] as const;
for (const key of required) {
if (!process.env[key]) {
@@ -15,7 +12,7 @@ for (const key of required) {
`Missing required environment variable: ${key}\n` +
`Copy .env.example to .env.local and fill in your Supabase credentials.\n` +
` cp .env.example .env.local`,
- )
+ );
}
}
@@ -25,4 +22,4 @@ export const env = {
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string,
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
},
-} as const
+} as const;
diff --git a/lib/game-scores.ts b/lib/game-scores.ts
new file mode 100644
index 0000000..5c6daf6
--- /dev/null
+++ b/lib/game-scores.ts
@@ -0,0 +1,75 @@
+import { createClient } from "@/lib/supabase/server";
+import type { GameScore } from "@/types/database";
+
+export interface InsertGameScorePayload {
+ sessionId: string;
+ topic: string;
+ userIdea: string;
+ scoreOriginality: number;
+ scorePracticality: number;
+ scoreUnexpectedness: number;
+ totalScore: number;
+ aiComment: string | null;
+}
+
+export async function insertGameScore(payload: InsertGameScorePayload): Promise {
+ const supabase = await createClient();
+ const { data, error } = await supabase
+ .from("game_scores")
+ .insert({
+ session_id: payload.sessionId,
+ topic: payload.topic,
+ user_idea: payload.userIdea,
+ score_originality: payload.scoreOriginality,
+ score_practicality: payload.scorePracticality,
+ score_unexpectedness: payload.scoreUnexpectedness,
+ total_score: payload.totalScore,
+ ai_comment: payload.aiComment,
+ })
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data as GameScore;
+}
+
+export async function getRecentScores(sessionId: string, limit = 10): Promise {
+ const supabase = await createClient();
+ const { data, error } = await supabase
+ .from("game_scores")
+ .select("*")
+ .eq("session_id", sessionId)
+ .order("played_at", { ascending: false })
+ .limit(Math.min(limit, 100));
+
+ if (error) throw error;
+ return (data ?? []) as GameScore[];
+}
+
+export async function getPersonalBest(sessionId: string): Promise {
+ const supabase = await createClient();
+ const { data, error } = await supabase
+ .from("game_scores")
+ .select("total_score")
+ .eq("session_id", sessionId)
+ .order("total_score", { ascending: false })
+ .limit(1)
+ .single();
+
+ if (error) {
+ if (error.code === "PGRST116") return null; // no rows
+ throw error;
+ }
+ return data?.total_score ?? null;
+}
+
+export async function getGamesPlayed(sessionId: string): Promise {
+ const supabase = await createClient();
+ const { count, error } = await supabase
+ .from("game_scores")
+ .select("*", { count: "exact", head: true })
+ .eq("session_id", sessionId);
+
+ if (error) throw error;
+ return count ?? 0;
+}
diff --git a/lib/openai.ts b/lib/openai.ts
new file mode 100644
index 0000000..1e2817e
--- /dev/null
+++ b/lib/openai.ts
@@ -0,0 +1,13 @@
+import OpenAI from "openai";
+
+let _client: OpenAI | null = null;
+
+export function getOpenAIClient(): OpenAI {
+ if (!_client) {
+ if (!process.env.OPENAI_API_KEY) {
+ throw new Error("OPENAI_API_KEY environment variable is not set");
+ }
+ _client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
+ }
+ return _client;
+}
diff --git a/lib/session.ts b/lib/session.ts
new file mode 100644
index 0000000..4c8178a
--- /dev/null
+++ b/lib/session.ts
@@ -0,0 +1,14 @@
+const SESSION_KEY = "game_session_id";
+
+export function getSessionId(): string {
+ if (typeof window === "undefined") {
+ throw new Error("getSessionId must be called in a browser context");
+ }
+
+ let sessionId = localStorage.getItem(SESSION_KEY);
+ if (!sessionId) {
+ sessionId = crypto.randomUUID();
+ localStorage.setItem(SESSION_KEY, sessionId);
+ }
+ return sessionId;
+}
diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts
index 83027c7..cba7e9c 100644
--- a/lib/supabase/client.ts
+++ b/lib/supabase/client.ts
@@ -1,6 +1,6 @@
-"use client"
+"use client";
-import { createBrowserClient } from "@supabase/ssr"
+import { createBrowserClient } from "@supabase/ssr";
/**
* Creates a Supabase client for use in Client Components.
@@ -10,5 +10,5 @@ export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
- )
+ );
}
diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts
index c090618..f9ec6f8 100644
--- a/lib/supabase/middleware.ts
+++ b/lib/supabase/middleware.ts
@@ -1,12 +1,12 @@
-import { createServerClient } from "@supabase/ssr"
-import { type NextRequest, NextResponse } from "next/server"
+import { createServerClient } from "@supabase/ssr";
+import { type NextRequest, NextResponse } from "next/server";
/**
* Refreshes the Supabase session on every request so the auth token stays
* current. Called from middleware.ts which runs on the Edge Runtime.
*/
export async function updateSession(request: NextRequest) {
- let supabaseResponse = NextResponse.next({ request })
+ let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -14,24 +14,24 @@ export async function updateSession(request: NextRequest) {
{
cookies: {
getAll() {
- return request.cookies.getAll()
+ return request.cookies.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options?: Record }[]) {
for (const { name, value } of cookiesToSet) {
- request.cookies.set(name, value)
+ request.cookies.set(name, value);
}
- supabaseResponse = NextResponse.next({ request })
+ supabaseResponse = NextResponse.next({ request });
for (const { name, value, options } of cookiesToSet) {
- supabaseResponse.cookies.set(name, value, options)
+ supabaseResponse.cookies.set(name, value, options);
}
},
},
},
- )
+ );
// Refresh the session — do NOT remove this call.
// It keeps the auth token alive and syncs server/client state.
- await supabase.auth.getUser()
+ await supabase.auth.getUser();
- return supabaseResponse
+ return supabaseResponse;
}
diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts
index 944d8c1..4c01954 100644
--- a/lib/supabase/server.ts
+++ b/lib/supabase/server.ts
@@ -1,12 +1,12 @@
-import { createServerClient } from "@supabase/ssr"
-import { cookies } from "next/headers"
+import { createServerClient } from "@supabase/ssr";
+import { cookies } from "next/headers";
/**
* Creates a Supabase client for use in Server Components and Route Handlers.
* Reads auth session from cookies so the server can access user-specific data.
*/
export async function createClient() {
- const cookieStore = await cookies()
+ const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -14,12 +14,12 @@ export async function createClient() {
{
cookies: {
getAll() {
- return cookieStore.getAll()
+ return cookieStore.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options?: Record }[]) {
try {
for (const { name, value, options } of cookiesToSet) {
- cookieStore.set(name, value, options)
+ cookieStore.set(name, value, options);
}
} catch {
// setAll is called from a Server Component — cookies can only be
@@ -28,5 +28,5 @@ export async function createClient() {
},
},
},
- )
+ );
}
diff --git a/lib/utils.ts b/lib/utils.ts
index 2ff78bd..30bcdf8 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -3,5 +3,5 @@
* Usage: cn("base", isActive && "active", undefined) → "base active"
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
- return classes.filter(Boolean).join(" ")
+ return classes.filter(Boolean).join(" ");
}
diff --git a/middleware.ts b/middleware.ts
index db8d12c..ef9018d 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,5 +1,5 @@
-import type { NextRequest } from "next/server"
-import { updateSession } from "@/lib/supabase/middleware"
+import { updateSession } from "@/lib/supabase/middleware";
+import type { NextRequest } from "next/server";
/**
* Edge middleware — runs on every request before the page renders.
@@ -12,7 +12,7 @@ import { updateSession } from "@/lib/supabase/middleware"
* }
*/
export async function middleware(request: NextRequest) {
- return await updateSession(request)
+ return await updateSession(request);
}
export const config = {
@@ -26,4 +26,4 @@ export const config = {
*/
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
-}
+};
diff --git a/next.config.ts b/next.config.ts
index d0214b6..41d37a9 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,8 +1,8 @@
-import type { NextConfig } from "next"
+import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// No custom configuration required for Vercel deployment.
// Add project-specific settings here as needed.
-}
+};
-export default nextConfig
+export default nextConfig;
diff --git a/package.json b/package.json
index 9afb16c..8df360c 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@supabase/ssr": "^0.5.0",
"@supabase/supabase-js": "^2.49.0",
"next": "^15.2.0",
+ "openai": "^6.32.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/specs/001-hirameki-battle/checklists/requirements.md b/specs/001-hirameki-battle/checklists/requirements.md
new file mode 100644
index 0000000..07057b3
--- /dev/null
+++ b/specs/001-hirameki-battle/checklists/requirements.md
@@ -0,0 +1,34 @@
+# Specification Quality Checklist: 閃き対決(ひらめきバトル)
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-02-27
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+All items pass. Spec is ready for `/speckit.plan`.
diff --git a/specs/001-hirameki-battle/contracts/api-routes.md b/specs/001-hirameki-battle/contracts/api-routes.md
new file mode 100644
index 0000000..09e2dc5
--- /dev/null
+++ b/specs/001-hirameki-battle/contracts/api-routes.md
@@ -0,0 +1,162 @@
+# API Contracts: 閃き対決
+
+**Branch**: `001-hirameki-battle` | **Date**: 2026-02-27
+
+All routes are Next.js Route Handlers under `app/api/`. All requests and responses use `Content-Type: application/json`.
+
+---
+
+## POST /api/generate-topic
+
+Generates a new random creative challenge topic (お題) via Claude AI.
+
+### Request
+
+```
+POST /api/generate-topic
+Content-Type: application/json
+```
+
+Body: empty (`{}`) — no parameters required.
+
+### Response: 200 OK
+
+```json
+{
+ "topic": "傘の新しい使い方"
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `topic` | `string` | お題 in "○○の新しい使い方" format |
+
+### Error Responses
+
+| Status | Body | When |
+|--------|------|------|
+| `503` | `{ "error": "トピック生成サービスが一時的に利用できません。再試行してください。" }` | Claude API unreachable or timeout |
+| `429` | `{ "error": "リクエストが多すぎます。しばらく待ってから再試行してください。", "retryAfter": 60 }` | Claude rate limit hit |
+| `500` | `{ "error": "予期しないエラーが発生しました。" }` | Unexpected server error |
+
+---
+
+## POST /api/score-idea
+
+Submits a player's idea for AI judging, persists the result to Supabase, and returns the scores.
+
+### Request
+
+```
+POST /api/score-idea
+Content-Type: application/json
+```
+
+```json
+{
+ "sessionId": "550e8400-e29b-41d4-a716-446655440000",
+ "topic": "傘の新しい使い方",
+ "idea": "水耕栽培の支柱として使う"
+}
+```
+
+| Field | Type | Required | Constraints |
+|-------|------|----------|-------------|
+| `sessionId` | `string (UUID)` | Yes | Valid UUID v4 |
+| `topic` | `string` | Yes | Non-empty |
+| `idea` | `string` | Yes | 1–500 characters |
+
+### Response: 200 OK
+
+```json
+{
+ "scoreId": 42,
+ "originality": 28,
+ "practicality": 25,
+ "unexpectedness": 20,
+ "totalScore": 73,
+ "comment": "実用的で面白いアイデアですが、もう一ひねり欲しいところです。"
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `scoreId` | `number` | Supabase row ID of the saved game score |
+| `originality` | `number` (0–33) | 独創性スコア |
+| `practicality` | `number` (0–33) | 実用性スコア |
+| `unexpectedness` | `number` (0–34) | 意外性スコア |
+| `totalScore` | `number` (0–100) | 合計スコア |
+| `comment` | `string` | AI一言コメント(日本語) |
+
+### Error Responses
+
+| Status | Body | When |
+|--------|------|------|
+| `400` | `{ "error": "アイデアを入力してください。" }` | `idea` is empty |
+| `400` | `{ "error": "アイデアは500文字以内で入力してください。" }` | `idea` exceeds 500 chars |
+| `400` | `{ "error": "無効なリクエスト形式です。" }` | Missing required fields |
+| `503` | `{ "error": "採点サービスが一時的に利用できません。入力内容は保持されています。" }` | Claude API unreachable |
+| `429` | `{ "error": "リクエストが多すぎます。しばらく待ってから再試行してください。", "retryAfter": 60 }` | Rate limit |
+| `500` | `{ "error": "採点に失敗しました。再試行してください。" }` | Unexpected error |
+
+**Note:** On error, the score is **not** saved to Supabase. The client should retain the user's input for retry.
+
+---
+
+## GET /api/scores?sessionId={uuid}
+
+Returns the score history for the given session.
+
+### Request
+
+```
+GET /api/scores?sessionId=550e8400-e29b-41d4-a716-446655440000
+```
+
+| Param | Type | Required | Description |
+|-------|------|----------|-------------|
+| `sessionId` | `string (UUID)` | Yes | Player's session ID |
+| `limit` | `number` | No | Max records to return (default: 10, max: 100) |
+
+### Response: 200 OK
+
+```json
+{
+ "scores": [
+ {
+ "id": 42,
+ "topic": "傘の新しい使い方",
+ "totalScore": 73,
+ "originality": 28,
+ "practicality": 25,
+ "unexpectedness": 20,
+ "comment": "実用的で面白いアイデアです。",
+ "playedAt": "2026-02-27T10:30:00Z"
+ }
+ ],
+ "personalBest": 73,
+ "gamesPlayed": 5
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `scores` | `array` | Recent scores, newest first |
+| `personalBest` | `number \| null` | Highest total score in this session |
+| `gamesPlayed` | `number` | Total games played in this session |
+
+### Error Responses
+
+| Status | Body | When |
+|--------|------|------|
+| `400` | `{ "error": "sessionIdは必須です。" }` | `sessionId` param missing |
+| `500` | `{ "error": "スコアの取得に失敗しました。" }` | DB error |
+
+---
+
+## Shared Conventions
+
+- All error bodies follow: `{ "error": string, "retryAfter"?: number }`
+- `retryAfter` field (seconds) is included only on `429` responses.
+- All timestamps are ISO 8601 UTC strings.
+- Server-side validation mirrors DB constraints (500-char limit, score ranges).
diff --git a/specs/001-hirameki-battle/data-model.md b/specs/001-hirameki-battle/data-model.md
new file mode 100644
index 0000000..9b8dfe2
--- /dev/null
+++ b/specs/001-hirameki-battle/data-model.md
@@ -0,0 +1,156 @@
+# Data Model: 閃き対決
+
+**Branch**: `001-hirameki-battle` | **Date**: 2026-02-27
+
+---
+
+## Entities
+
+### 1. GameScore (Persisted — Supabase)
+
+Represents a single completed game session with its AI-judged result.
+
+| Field | Type | Constraints | Notes |
+|-------|------|-------------|-------|
+| `id` | `bigint` | PK, auto-increment | Internal row ID |
+| `session_id` | `uuid` | NOT NULL | Client-generated; groups plays from same browser |
+| `topic` | `text` | NOT NULL | AI-generated お題 (e.g. "傘の新しい使い方") |
+| `user_idea` | `text` | NOT NULL, max 500 chars | Player's submitted idea |
+| `score_originality` | `int` | NOT NULL, 0–33 | 独創性 axis score |
+| `score_practicality` | `int` | NOT NULL, 0–33 | 実用性 axis score |
+| `score_unexpectedness` | `int` | NOT NULL, 0–34 | 意外性 axis score |
+| `total_score` | `int` | NOT NULL, 0–100 | Sum of the three axis scores |
+| `ai_comment` | `text` | Nullable | AI one-line feedback in Japanese |
+| `played_at` | `timestamptz` | NOT NULL, default `now()` | When the game was played |
+
+**State transitions:**
+- A `GameScore` row is written **once** (INSERT only; no UPDATE).
+- A row is created only after Claude returns a valid score. If scoring fails, no row is inserted.
+
+**Validation rules (enforced at DB level):**
+```sql
+check (score_originality between 0 and 33)
+check (score_practicality between 0 and 33)
+check (score_unexpectedness between 0 and 34)
+check (total_score between 0 and 100)
+check (char_length(user_idea) <= 500)
+```
+
+---
+
+### 2. SessionContext (Client-side only — localStorage)
+
+Represents the anonymous player identity for a browser. Never persisted in the database directly; only `session_id` is stored as a foreign key in `game_scores`.
+
+| Field | Type | Storage | Notes |
+|-------|------|---------|-------|
+| `sessionId` | `string (UUID v4)` | `localStorage["game_session_id"]` | Generated once per browser; reused across page loads |
+
+**Lifecycle:**
+- Generated on first game start if absent in localStorage.
+- Persists across page refreshes and browser restarts.
+- Lost only if user clears browser storage.
+
+---
+
+### 3. IdeaScore (In-memory — API response)
+
+Transient object returned by `/api/score-idea`. Populated from Claude's Tool Use response. Not stored independently; its fields are mapped directly to `GameScore` columns before insertion.
+
+| Field | Type | Notes |
+|-------|------|-------|
+| `originality` | `number` (0–33) | From Claude tool call |
+| `practicality` | `number` (0–33) | From Claude tool call |
+| `unexpectedness` | `number` (0–34) | From Claude tool call |
+| `comment` | `string` | Japanese feedback text |
+| `totalScore` | `number` | Computed: sum of three axes |
+
+---
+
+## SQL DDL
+
+```sql
+-- Migration: 001_game_scores.sql
+create table if not exists public.game_scores (
+ id bigserial primary key,
+ session_id uuid not null,
+ topic text not null,
+ user_idea text not null check (char_length(user_idea) <= 500),
+ score_originality int not null check (score_originality between 0 and 33),
+ score_practicality int not null check (score_practicality between 0 and 33),
+ score_unexpectedness int not null check (score_unexpectedness between 0 and 34),
+ total_score int not null check (total_score between 0 and 100),
+ ai_comment text,
+ played_at timestamptz not null default now()
+);
+
+-- Row Level Security
+alter table public.game_scores enable row level security;
+
+create policy "Anyone can insert game scores"
+ on public.game_scores for insert
+ with check (true);
+
+create policy "Anyone can read game scores"
+ on public.game_scores for select
+ using (true);
+-- No UPDATE or DELETE policies → blocked for all anon users
+
+-- Indexes
+create index idx_game_scores_session_played
+ on public.game_scores (session_id, played_at desc);
+
+create index idx_game_scores_total_score
+ on public.game_scores (total_score desc, played_at desc);
+
+create index idx_game_scores_played_at
+ on public.game_scores (played_at desc);
+```
+
+---
+
+## Relationships
+
+```
+Browser (localStorage)
+ └── session_id (UUID) ─────────────────────────┐
+ │
+Supabase: game_scores │
+ id ← auto │
+ session_id ← ─────────────────────────── matches┘
+ topic ← from /api/generate-topic
+ user_idea ← from user input
+ score_* ← from /api/score-idea (Claude)
+ total_score ← computed server-side
+ ai_comment ← from /api/score-idea (Claude)
+ played_at ← server timestamp
+```
+
+---
+
+## Query Patterns
+
+### Personal best (for this session)
+```sql
+select * from game_scores
+where session_id = $1
+order by total_score desc
+limit 1;
+-- Uses: idx_game_scores_session_played
+```
+
+### Recent 10 games (for this session)
+```sql
+select * from game_scores
+where session_id = $1
+order by played_at desc
+limit 10;
+-- Uses: idx_game_scores_session_played
+```
+
+### Count games played (for this session)
+```sql
+select count(*) from game_scores
+where session_id = $1;
+-- Uses: idx_game_scores_session_played
+```
diff --git a/specs/001-hirameki-battle/plan.md b/specs/001-hirameki-battle/plan.md
new file mode 100644
index 0000000..be28feb
--- /dev/null
+++ b/specs/001-hirameki-battle/plan.md
@@ -0,0 +1,240 @@
+# Implementation Plan: 閃き対決(ひらめきバトル)
+
+**Branch**: `001-hirameki-battle` | **Date**: 2026-02-27 | **Spec**: [spec.md](./spec.md)
+
+## Summary
+
+「閃き対決」はソロ挑戦型アイデアバトルゲームです。Claude AIがランダムなお題を生成し、60秒のカウントダウン中にプレイヤーがアイデアを入力。AIが独創性・実用性・意外性の3軸で採点(合計100点)し、Supabaseにスコアを永続化する。Next.js 15 App Router + Anthropic SDK + Supabase で実装する。
+
+---
+
+## Technical Context
+
+**Language/Version**: TypeScript 5.7, Node.js (Next.js runtime)
+**Primary Dependencies**: Next.js 15, `@anthropic-ai/sdk` (new), `@supabase/supabase-js` v2, `@supabase/ssr` v0.5
+**Storage**: Supabase PostgreSQL — `game_scores` table (see data-model.md)
+**Testing**: Vitest v2 + Testing Library, 80% coverage threshold
+**Target Platform**: Web (desktop + mobile browsers)
+**Project Type**: Web application (Next.js App Router)
+**Performance Goals**:
+ - `/api/generate-topic`: <3s p95 (external AI call)
+ - `/api/score-idea`: <10s p95 (external AI call; spec SC-003)
+ - Page loads: LCP ≤ 2.5s
+**Constraints**: No user auth, 500-char input limit, session UUID in localStorage
+**Scale/Scope**: Single-user game sessions, anonymous score history per browser
+
+---
+
+## Constitution Check
+
+| Principle | Status | Notes |
+|-----------|--------|-------|
+| I. Code Quality | ✅ Pass | Single-responsibility components; Biome enforced |
+| II. Testing Standards | ✅ Pass | Unit tests for scoring logic; integration tests for API routes and timer flow |
+| III. UX Consistency | ✅ Pass | Loading, error, empty states required for all interactive elements |
+| IV. Performance Requirements | ⚠️ Justified exception | AI API routes exceed 200ms p95; see Complexity Tracking |
+
+---
+
+## Complexity Tracking
+
+| Violation | Why Needed | Simpler Alternative Rejected Because |
+|-----------|------------|-------------------------------------|
+| `/api/generate-topic` and `/api/score-idea` respond in 2–8s (exceeds 200ms p95 constitution target) | Claude AI inference is the core product feature; latency is inherent to LLM API calls | Caching topics degrades gameplay (same お題 repeats); pre-generating scores server-side is not possible without player input |
+
+---
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/001-hirameki-battle/
+├── plan.md # This file
+├── spec.md # Feature specification
+├── research.md # Phase 0 research findings
+├── data-model.md # Entity definitions + SQL DDL
+├── quickstart.md # Developer setup guide
+├── contracts/
+│ └── api-routes.md # API contract definitions
+└── tasks.md # Phase 2 output (/speckit.tasks)
+```
+
+### Source Code (repository root)
+
+```text
+app/
+├── page.tsx # Home page — landing + Start button
+├── game/
+│ └── page.tsx # Game page — topic, timer, input
+├── result/
+│ └── page.tsx # Result page — scores, replay
+├── history/
+│ └── page.tsx # History page — score list
+└── api/
+ ├── generate-topic/
+ │ └── route.ts # POST: generate AI お題
+ ├── score-idea/
+ │ └── route.ts # POST: score idea + save to DB
+ └── scores/
+ └── route.ts # GET: fetch session score history
+
+components/
+├── features/
+│ ├── game/
+│ │ ├── GameTimer.tsx # Countdown timer (Client Component)
+│ │ ├── IdeaInput.tsx # Text input + submit button (Client)
+│ │ └── TopicDisplay.tsx # Displays the お題
+│ ├── result/
+│ │ ├── ScoreBreakdown.tsx # 3-axis score display
+│ │ └── AiComment.tsx # AI comment display
+│ └── history/
+│ └── ScoreList.tsx # Score history list
+└── ui/
+ └── (existing shared components — reuse before adding new)
+
+lib/
+├── anthropic.ts # Anthropic client singleton
+├── game-scores.ts # Supabase query helpers (insert, fetch)
+├── session.ts # localStorage session_id helper
+└── supabase/ # (existing)
+
+types/
+├── database.ts # Add GameScore interface (extend existing)
+└── game.ts # IdeaScore, TopicResponse, ScoreResponse
+
+supabase/
+└── migrations/
+ └── 001_game_scores.sql # game_scores table + RLS + indexes
+
+tests/
+├── api/
+│ ├── generate-topic.test.ts # Route handler tests
+│ ├── score-idea.test.ts # Route handler + scoring logic tests
+│ └── scores.test.ts # Score history endpoint tests
+├── components/
+│ ├── GameTimer.test.tsx # Timer countdown behavior
+│ ├── IdeaInput.test.tsx # Input validation + submit
+│ └── ScoreBreakdown.test.tsx # Score display
+└── lib/
+ ├── game-scores.test.ts # Supabase helpers (mocked)
+ └── session.test.ts # localStorage session ID logic
+```
+
+**Structure Decision**: Next.js App Router conventions. Feature components grouped by page (`game/`, `result/`, `history/`). Server-only logic (AI calls, DB writes) confined to `app/api/` routes. Client Components isolated to interactive elements only (timer, input form).
+
+---
+
+## Implementation Phases
+
+### Phase 0: Research ✅ Complete
+
+See [research.md](./research.md) for all findings. Key decisions:
+- Tool Use pattern for structured Claude scoring output
+- Non-streaming AI calls
+- `game_scores` table with session UUID (localStorage)
+- Score saved server-side in `/api/score-idea`
+
+---
+
+### Phase 1: Infrastructure
+
+**Goal:** Database, environment, and AI client ready; no game UI yet.
+
+**Deliverables:**
+1. `bun add @anthropic-ai/sdk` — add to dependencies
+2. `.env.example` updated with `ANTHROPIC_API_KEY`
+3. `lib/anthropic.ts` — Anthropic client singleton
+4. `lib/session.ts` — localStorage session ID helper
+5. `supabase/migrations/001_game_scores.sql` — DDL + RLS + indexes
+6. `types/game.ts` — TypeScript interfaces
+7. `types/database.ts` — `GameScore` interface added
+8. `lib/game-scores.ts` — Supabase query helpers
+
+**Tests:** Unit tests for `session.ts` (localStorage logic); `game-scores.ts` (mocked Supabase).
+
+---
+
+### Phase 2: API Routes
+
+**Goal:** Three Route Handlers functional and tested.
+
+**Deliverables:**
+1. `app/api/generate-topic/route.ts` — POST, calls Claude, returns topic
+2. `app/api/score-idea/route.ts` — POST, calls Claude (Tool Use), saves to DB, returns scores
+3. `app/api/scores/route.ts` — GET, queries Supabase by session_id
+
+**Tests:** Route handler tests with mocked Anthropic SDK and Supabase client.
+
+---
+
+### Phase 3: Game UI (P1 — Core Game Loop)
+
+**Goal:** Complete game cycle playable end-to-end (P1 user story).
+
+**Deliverables:**
+1. `components/features/game/GameTimer.tsx` — 60s countdown, `onExpire` callback
+2. `components/features/game/IdeaInput.tsx` — textarea (max 500 chars), submit button
+3. `components/features/game/TopicDisplay.tsx` — displays generated お題
+4. `app/game/page.tsx` — orchestrates topic fetch, timer, input, submit flow
+5. `components/features/result/ScoreBreakdown.tsx` — shows 3-axis scores
+6. `components/features/result/AiComment.tsx` — displays AI comment
+7. `app/result/page.tsx` — reads result from sessionStorage, displays scores
+8. `app/page.tsx` — landing page with Start button
+9. Loading, error, and timeout states for all interactive elements
+
+**Tests:** Timer component (countdown, expire, pause on submit); input validation; score display.
+
+---
+
+### Phase 4: History & Replay (P2 + P3)
+
+**Goal:** Score persistence visible to user; replay flow.
+
+**Deliverables:**
+1. `components/features/history/ScoreList.tsx` — score history list
+2. `app/history/page.tsx` — fetches and displays session score history
+3. "もう一度" button on result page → clears sessionStorage, navigates to game
+4. Personal best highlight in history view
+
+**Tests:** ScoreList renders correctly (empty, single, multiple); navigation from result → game.
+
+---
+
+### Phase 5: Polish
+
+**Goal:** UI consistency, error resilience, performance verification.
+
+**Deliverables:**
+1. Unified loading skeleton for AI call wait states
+2. Error boundary on game and result pages
+3. Character counter on IdeaInput (shows 0/500)
+4. Verify LCP ≤ 2.5s on game and result pages (Lighthouse)
+5. Verify AI route p95 within spec targets (manual test 10 samples)
+6. Biome lint + format pass on all new files
+
+---
+
+## Key Design Decisions
+
+### Score Saving: Server-Side Only
+
+Claude scoring and Supabase insertion both happen inside `/api/score-idea`. The client never writes directly to Supabase. This prevents score tampering.
+
+### Session Identity
+
+`crypto.randomUUID()` persisted in `localStorage["game_session_id"]`. Generated on first game start. Sent in request body to `/api/score-idea`. The API route does not validate session ownership (public leaderboard; no privacy concern).
+
+### Game State Between Pages
+
+`sessionStorage` stores the most recent result object (topic, scores, comment) when navigating from game → result. This avoids URL length constraints from long AI comments.
+
+### Timer Expiry Flow
+
+When `GameTimer` calls `onExpire()`:
+1. Textarea becomes disabled
+2. Submit button becomes disabled
+3. "時間切れ!" overlay shown
+4. "もう一度" button offered to restart
+
+If user submits before timer expires, timer pauses immediately (cannot re-submit).
diff --git a/specs/001-hirameki-battle/quickstart.md b/specs/001-hirameki-battle/quickstart.md
new file mode 100644
index 0000000..07fa0e2
--- /dev/null
+++ b/specs/001-hirameki-battle/quickstart.md
@@ -0,0 +1,76 @@
+# Quickstart: 閃き対決
+
+**Branch**: `001-hirameki-battle`
+
+## Prerequisites
+
+- Bun installed
+- Supabase project (free tier works)
+- Anthropic API key ([console.anthropic.com](https://console.anthropic.com))
+
+## Setup
+
+### 1. Install dependencies
+
+```bash
+bun install
+```
+
+The `@anthropic-ai/sdk` package is required and listed in `dependencies`.
+
+### 2. Configure environment
+
+```bash
+cp .env.example .env.local
+```
+
+Edit `.env.local`:
+
+```
+# Supabase (existing)
+NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
+NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
+
+# Anthropic (new — server-only, never expose to browser)
+ANTHROPIC_API_KEY=sk-ant-...
+```
+
+### 3. Run database migration
+
+In Supabase Dashboard → SQL Editor, run:
+
+```sql
+-- paste contents of supabase/migrations/001_game_scores.sql
+```
+
+Or if using Supabase CLI:
+
+```bash
+supabase db push
+```
+
+### 4. Start dev server
+
+```bash
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) and press Start to play.
+
+## Verify Setup
+
+- **Topic generation**: Press Start; お題 should appear within 3 seconds.
+- **Scoring**: Enter an idea and submit; score should appear within 10 seconds.
+- **History**: After playing, check `/history` to see saved scores.
+
+## Run Tests
+
+```bash
+bun run test:ci
+```
+
+## Lint + Format
+
+```bash
+bunx biome check --apply .
+```
diff --git a/specs/001-hirameki-battle/research.md b/specs/001-hirameki-battle/research.md
new file mode 100644
index 0000000..05a8e8e
--- /dev/null
+++ b/specs/001-hirameki-battle/research.md
@@ -0,0 +1,161 @@
+# Research: 閃き対決
+
+**Branch**: `001-hirameki-battle` | **Date**: 2026-02-27
+
+---
+
+## 1. Claude API Integration in Next.js 15
+
+### Decision: Tool Use Pattern (Structured JSON) + Non-Streaming
+
+**Rationale:**
+Scoring requires a complete, structured response (3 integer scores + string comment) before anything can be displayed. Streaming provides no UX benefit here — the user sees a loading state until all scores arrive simultaneously.
+
+Tool Use enforces a JSON schema at the model level, eliminating the need for fragile JSON parsing of free-text responses.
+
+**Key choices:**
+
+| Aspect | Decision | Rationale |
+|--------|----------|-----------|
+| Model | `claude-sonnet-4-6` | Latest model, best structured-output reliability |
+| Streaming | No | Scores must arrive complete; streaming adds complexity with no UX gain |
+| JSON Output | Tool Use (`submit_score` tool) | Schema enforced by Claude, not manual parsing |
+| Client | Singleton in `lib/anthropic.ts` | Avoid re-creating client per request |
+| API Routes | Server-side only (`app/api/`) | API key must never reach the browser |
+
+**Topic generation prompt pattern:**
+```
+Generate a random Japanese creative challenge topic in the format: "○○の新しい使い方"
+Return ONLY the topic text, nothing else.
+```
+
+**Scoring tool schema:**
+```json
+{
+ "name": "submit_score",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "originality": { "type": "integer", "minimum": 0, "maximum": 33 },
+ "practicality": { "type": "integer", "minimum": 0, "maximum": 33 },
+ "unexpectedness": { "type": "integer", "minimum": 0, "maximum": 34 },
+ "comment": { "type": "string" }
+ },
+ "required": ["originality", "practicality", "unexpectedness", "comment"]
+ }
+}
+```
+
+**Error handling strategy:** Catch `RateLimitError`, `APIConnectionError`, `APIStatusError` separately. Return user-friendly Japanese error messages with HTTP status codes.
+
+**Alternatives considered:**
+- Free-text JSON prompt → Rejected: brittle, requires manual parsing + validation
+- Streaming → Rejected: no UX benefit for complete-score display
+- GPT-4o → Rejected: project already uses Claude API; consistency
+
+---
+
+## 2. Anonymous Score Storage in Supabase
+
+### Decision: Session ID (localStorage) + `game_scores` table, no Supabase Auth
+
+**Rationale:**
+Anonymous Supabase Auth adds JWT refresh complexity and extra HTTP calls. For a game where scores are per-browser-session, `crypto.randomUUID()` persisted in localStorage is sufficient and simpler.
+
+Score saving happens **server-side** in the `/api/score-idea` route to prevent client-side tampering.
+
+**Schema:**
+```sql
+create table public.game_scores (
+ id bigserial primary key,
+ session_id uuid not null,
+ topic text not null,
+ user_idea text not null check (char_length(user_idea) <= 500),
+ score_originality int not null check (score_originality between 0 and 33),
+ score_practicality int not null check (score_practicality between 0 and 33),
+ score_unexpectedness int not null check (score_unexpectedness between 0 and 34),
+ total_score int not null check (total_score between 0 and 100),
+ ai_comment text,
+ played_at timestamptz not null default now()
+);
+```
+
+**RLS policies:** INSERT and SELECT allowed for all (anon key). UPDATE and DELETE omitted → blocked automatically.
+
+**Performance:** Three indexes cover the three query patterns (personal best, recent games, session history).
+
+**Alternatives considered:**
+- Supabase Anonymous Auth → Rejected: added complexity for no benefit at MVP
+- Client-side score saving → Rejected: scores can be tampered; server-side save via API route is safer
+- No persistence → Rejected: spec requires FR-007 and HS display
+
+---
+
+## 3. React Timer Pattern (Client Component)
+
+### Decision: `useEffect` countdown in a dedicated `GameTimer` Client Component
+
+**Rationale:**
+The countdown timer is purely client-side (requires `setInterval`). Isolate it in a Client Component to keep the game page mostly server-rendered. The parent game page passes `onExpire` callback.
+
+```tsx
+"use client"
+// components/features/game/GameTimer.tsx
+// Uses useEffect + setInterval for 60s countdown
+// Calls onExpire() when reaches 0
+// Calls onTick(remaining) to update display
+```
+
+**Alternatives considered:**
+- `setTimeout` loop → Rejected: `setInterval` more accurate for countdown display
+- Server-side timer → Not possible (browser-only)
+
+---
+
+## 4. Game State Management
+
+### Decision: URL-based navigation between pages (no global state store)
+
+**Rationale:**
+The game has 4 distinct screens (Home → Game → Result → History). Using Next.js routes with `router.push()` and URL search params (for passing scores between game and result) is simpler than adding a global state store.
+
+Score data passed from game to result via URL query params (total_score, breakdown, topic encoded as params) or via a temporary client store (`sessionStorage`).
+
+**Decision:** Use `sessionStorage` to pass the full result object from game page to result page (avoids URL length issues with AI comment text).
+
+**Alternatives considered:**
+- React Context / Zustand → Rejected: over-engineering for a linear game flow
+- URL params only → Rejected: AI comment text can be long (URL encoding issues)
+
+---
+
+## 5. New Dependency Required
+
+**Package:** `@anthropic-ai/sdk`
+
+Must be added to `dependencies` in `package.json`:
+```bash
+bun add @anthropic-ai/sdk
+```
+
+**Environment variable to add:**
+```
+ANTHROPIC_API_KEY=sk-ant-...
+```
+Add to `.env.example` and document in README.
+
+---
+
+## Summary of Key Decisions
+
+| Topic | Decision |
+|-------|----------|
+| AI client | `@anthropic-ai/sdk`, singleton in `lib/anthropic.ts` |
+| AI calls | Server-side API routes only |
+| Scoring output | Tool Use pattern (schema-enforced JSON) |
+| Streaming | No |
+| Score storage | Supabase `game_scores` table |
+| Auth | None — session UUID in localStorage |
+| Score save timing | Server-side in `/api/score-idea` route |
+| Game state | `sessionStorage` between pages |
+| Timer | Client Component (`GameTimer.tsx`) |
diff --git a/specs/001-hirameki-battle/spec.md b/specs/001-hirameki-battle/spec.md
new file mode 100644
index 0000000..8b34c1d
--- /dev/null
+++ b/specs/001-hirameki-battle/spec.md
@@ -0,0 +1,124 @@
+# Feature Specification: 閃き対決(ひらめきバトル)
+
+**Feature Branch**: `001-hirameki-battle`
+**Created**: 2026-02-27
+**Status**: Draft
+
+## Overview
+
+「閃き(ひらめき)」をテーマにしたソロ挑戦型アイデアバトルゲーム。AIがランダムなお題を生成し、プレイヤーが制限時間内に独創的なアイデアを入力する。AIが独創性・実用性・意外性の3軸で採点(合計100点)し、ハイスコアを競う。
+
+---
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - ゲームをプレイする (Priority: P1)
+
+プレイヤーがトップページを開き、「スタート」を押すとAIがランダムなお題を表示する。60秒のカウントダウンが始まり、プレイヤーはお題に対するアイデアをテキストで入力して送信する。AIが採点結果(各軸のスコアと一言コメント)を表示する。
+
+**Why this priority**: ゲームの基本体験そのものであり、これ単体でMVPとして成立する。
+
+**Independent Test**: スタートからスコア表示まで一通りの流れを実行し、3軸合計100点のスコアとAIコメントが画面に表示されることを確認できる。
+
+**Acceptance Scenarios**:
+
+1. **Given** トップページを開いた状態で、**When** 「スタート」ボタンを押す、**Then** AIが生成したお題と60秒のカウントダウンタイマーが表示される。
+2. **Given** お題とタイマーが表示されている状態で、**When** アイデアを入力して送信する、**Then** タイマーが停止し採点中インジケーターが表示される。
+3. **Given** 採点中の状態で、**When** AI採点が完了する、**Then** 独創性・実用性・意外性の個別スコアと合計点、および一言コメントが表示される。
+4. **Given** タイマーが表示されている状態で、**When** 60秒が経過してもアイデアが未送信、**Then** タイマーが切れたことを示すメッセージが表示され、入力が無効になる。
+5. **Given** タイマー中に、**When** テキスト入力エリアが空のまま送信ボタンを押す、**Then** 送信は受け付けられずエラーメッセージが表示される。
+
+---
+
+### User Story 2 - ハイスコアを記録・確認する (Priority: P2)
+
+スコア結果が表示された後、今回のスコアが自動的に保存される。プレイヤーは自分の過去スコア一覧(最高得点・直近5件など)を確認できる。
+
+**Why this priority**: リプレイ動機となるコア要素。記録がなければゲームの継続性が失われる。
+
+**Independent Test**: ゲームを複数回プレイし、スコア一覧に各回の記録(お題・スコア・日時)が正しく表示されることを確認できる。
+
+**Acceptance Scenarios**:
+
+1. **Given** ゲームが完了した状態で、**When** スコア結果が表示される、**Then** スコアが自動的に永続化される。
+2. **Given** スコアが1件以上存在する状態で、**When** ハイスコア画面を開く、**Then** お題・合計スコア・日時を含む記録一覧が新しい順で表示される。
+3. **Given** ハイスコア画面を開いた状態で、**When** 自己ベストが更新されるゲームを完了した、**Then** 自己ベストとして強調表示される。
+
+---
+
+### User Story 3 - 結果画面からリプレイする (Priority: P3)
+
+採点結果を見たプレイヤーが「もう一度」ボタンを押すと、即座に新しいお題で次のゲームが始まる。
+
+**Why this priority**: セッション継続性のUX改善。P1・P2が完成していれば独立して追加できる。
+
+**Independent Test**: 結果画面の「もう一度」ボタンを押し、前回とは異なるお題でゲームが即座に開始されることを確認できる。
+
+**Acceptance Scenarios**:
+
+1. **Given** 採点結果が表示された状態で、**When** 「もう一度」ボタンを押す、**Then** 新しいお題と初期化されたタイマーで次のゲームが開始される。
+2. **Given** 連続してリプレイする状態で、**When** 3回連続で同じゲームをプレイする、**Then** 毎回異なるお題が表示される。
+
+---
+
+### Edge Cases
+
+- タイムアウト後に送信しようとした場合:入力は無効とし、タイムアップ画面を表示する。
+- AI採点に失敗した(外部サービスエラー)場合:ユーザーにエラーを通知し、再試行できるようにする。スコアは保存しない。
+- お題生成に失敗した場合:ゲーム開始ができない旨を通知し、リトライを促す。
+- 極端に短い入力(1文字など)の場合:採点は行われるが、低スコアになることをUIで示唆する。
+- 長すぎる入力(500文字超など)の場合:文字数制限を設け、制限超過時に入力を受け付けない。
+- ネットワーク切断中に採点を送信した場合:エラーを表示し、入力内容を保持した上で再試行できるようにする。
+
+---
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: システムはゲーム開始時に毎回ユニークなお題を生成・表示しなければならない。お題は日常的なモノや状況を対象とした「○○の新しい使い方・活用法」形式とする。
+- **FR-002**: システムはゲーム開始と同時に60秒のカウントダウンタイマーを表示・動作させなければならない。
+- **FR-003**: ユーザーはタイマー動作中にテキストエリアへアイデアを入力し、送信できなければならない。
+- **FR-004**: ユーザーが送信したアイデアは、タイムアウト前に限り有効とする。タイムアウト後の送信は受け付けない。
+- **FR-005**: システムはユーザーのアイデアを以下3軸で採点しなければならない:
+ - 独創性(0〜33点):既成概念にとらわれない独自性
+ - 実用性(0〜33点):実際に使えそうか・現実的か
+ - 意外性(0〜34点):予想外・驚きがあるか
+ - 合計100点満点
+- **FR-006**: システムは採点後に3軸の個別スコアと合計スコア、および一言コメント(採点理由・講評)を表示しなければならない。
+- **FR-007**: システムは採点が完了したゲーム結果(お題・アイデア・各スコア・合計・コメント・日時)を永続化しなければならない。
+- **FR-008**: ユーザーは自分の過去ゲーム結果を一覧で参照できなければならない。
+- **FR-009**: 結果画面に「もう一度」ボタンを設け、押すと即座に次のゲームが開始されなければならない。
+- **FR-010**: テキスト入力は最大500文字とし、超過時は入力を拒否しなければならない。
+- **FR-011**: テキスト入力が空の場合、送信を受け付けてはならない。
+- **FR-012**: AI採点中はローディングインジケーターを表示し、完了まで再送信できないようにしなければならない。
+
+### Key Entities
+
+- **ゲームセッション**: 1回のゲームプレイ記録。お題・入力アイデア・独創性スコア・実用性スコア・意外性スコア・合計スコア・AIコメント・プレイ日時を含む。
+- **お題**: AIが生成するプロンプト文字列。「○○の新しい使い方」形式で、毎回ユニークである。
+- **採点結果**: 3軸スコアの内訳・合計・一言コメントの集合体。ゲームセッションに紐付く。
+
+---
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: ユーザーがゲーム開始からスコア表示まで1回完結できる(完全なゲームサイクルが動作する)。
+- **SC-002**: お題生成から画面表示まで3秒以内に完了する。
+- **SC-003**: アイデア送信から採点結果表示まで10秒以内に完了する。
+- **SC-004**: タイムアウトした場合に、ユーザーが適切なフィードバックを受け取り次のアクション(リトライ)が明確である。
+- **SC-005**: 5回連続してリプレイしても、毎回異なるお題が表示される(お題の重複なし)。
+- **SC-006**: ゲーム結果が永続化され、ハイスコア画面で正しく参照できる。
+- **SC-007**: ゲームサイクル全体(スタート→採点→リプレイ)がエラーなく完結する。
+
+---
+
+## Assumptions
+
+- ユーザー認証は本フェーズでは不要。匿名プレイヤーとして同一デバイスのスコアを管理する。
+- お題は日本語のみ対応(英語対応は対象外)。
+- 複数プレイヤーの対戦機能(マルチプレイ)は将来拡張として対象外。
+- AI採点の評価基準は固定(独創性・実用性・意外性の3軸)。軸のカスタマイズは対象外。
+- スコア保存は永続的(有効期限なし)だが、上限件数(例:直近100件)を設ける想定。
diff --git a/specs/001-hirameki-battle/tasks.md b/specs/001-hirameki-battle/tasks.md
new file mode 100644
index 0000000..d48b82c
--- /dev/null
+++ b/specs/001-hirameki-battle/tasks.md
@@ -0,0 +1,231 @@
+# Tasks: 閃き対決(ひらめきバトル)
+
+**Input**: Design documents from `specs/001-hirameki-battle/`
+**Branch**: `001-hirameki-battle`
+**Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md)
+
+**Tests**: Included — required by project constitution (Principle II: Testing Standards).
+
+**Organization**: Tasks grouped by user story for independent implementation and testing.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
+- **[Story]**: US1 / US2 / US3 — maps to user stories from spec.md
+- Exact file paths included in all task descriptions
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: Project dependencies, environment configuration, database schema, and shared type definitions. No game logic yet.
+
+- [x] T001 Install @anthropic-ai/sdk via `bun add @anthropic-ai/sdk` and verify package.json updated
+- [x] T002 [P] Add `ANTHROPIC_API_KEY=sk-ant-...` entry to `.env.example` with comment "server-only, never expose to browser"
+- [x] T003 [P] Create `lib/anthropic.ts` — Anthropic client singleton (server-only import guard, `new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })`)
+- [x] T004 [P] Create `lib/session.ts` — `getSessionId()` helper using `crypto.randomUUID()` persisted in `localStorage["game_session_id"]`
+- [x] T005 [P] Create `types/game.ts` — interfaces: `IdeaScore`, `TopicResponse`, `ScoreResponse`, `GameResult`, `ScoreHistoryResponse`
+- [x] T006 [P] Add `GameScore` interface to `types/database.ts` (fields: id, session_id, topic, user_idea, score_originality, score_practicality, score_unexpectedness, total_score, ai_comment, played_at)
+- [x] T007 Create `supabase/migrations/001_game_scores.sql` — `game_scores` table DDL with CHECK constraints, RLS policies (INSERT + SELECT for anon), and three indexes (session_id+played_at, total_score+played_at, played_at)
+
+**Checkpoint**: `bun run type-check` passes; migration file is valid SQL; `.env.example` documents all required vars.
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: Supabase query helpers and their unit tests. MUST complete before any user story implementation.
+
+**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
+
+- [x] T008 Create `lib/game-scores.ts` — Supabase helpers: `insertGameScore(payload)`, `getRecentScores(sessionId, limit)`, `getPersonalBest(sessionId)`, `getGamesPlayed(sessionId)`
+- [x] T009 [P] Create `tests/lib/session.test.ts` — unit tests: generates UUID on first call, returns same UUID on subsequent calls, generates new UUID after localStorage cleared
+- [x] T010 [P] Create `tests/lib/game-scores.test.ts` — unit tests with mocked Supabase: insertGameScore inserts correct fields, getRecentScores returns newest-first ordered results, getPersonalBest returns highest total_score
+
+**Checkpoint**: `bun run test:ci` passes for Phase 1–2 tests; `lib/game-scores.ts` and `lib/session.ts` have ≥ 80% coverage.
+
+---
+
+## Phase 3: User Story 1 - ゲームをプレイする (Priority: P1) 🎯 MVP
+
+**Goal**: Complete game cycle end-to-end — Home → お題生成 → 60秒タイマー → アイデア入力 → 送信 → AI採点 → スコア表示。
+
+**Independent Test**: `GET /` → press Start → お題 appears → enter any text → submit → see 3-axis scores and AI comment on result page. No history or replay needed.
+
+### API Routes for User Story 1
+
+- [x] T011 [P] [US1] Create `app/api/generate-topic/route.ts` — POST handler: calls `anthropic.messages.create` with claude-sonnet-4-6, returns `{ topic: string }`; handles RateLimitError (429), APIConnectionError (503), unexpected errors (500) with Japanese error messages
+- [x] T012 [P] [US1] Create `app/api/score-idea/route.ts` — POST handler: validates `{ sessionId, topic, idea }` (idea 1–500 chars required), calls Claude with Tool Use `submit_score` schema, saves result via `insertGameScore`, returns `ScoreResponse`; returns 503 with input-preserving error on Claude failure (no DB write on error)
+
+### Tests for User Story 1 API
+
+- [x] T013 [US1] Create `tests/api/generate-topic.test.ts` — mocked Anthropic SDK: returns topic on success, returns 503 on connection error, returns 429 on rate limit
+- [x] T014 [US1] Create `tests/api/score-idea.test.ts` — mocked Anthropic SDK + Supabase: returns scores on success, rejects empty idea (400), rejects idea >500 chars (400), returns 503 without DB insert on Claude error
+
+### UI Components for User Story 1
+
+- [x] T015 [P] [US1] Create `components/features/game/GameTimer.tsx` — Client Component: 60s countdown display, accepts `onExpire()` and `onTick(remaining: number)` props; pauses when `isPaused` prop is true; shows "時間切れ!" at 0
+- [x] T016 [P] [US1] Create `components/features/game/IdeaInput.tsx` — Client Component: `