diff --git a/app/locales/en.json b/app/locales/en.json index 137b679..d838748 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -20,5 +20,16 @@ "rules.time": "60 seconds time limit", "rules.tap": "Tap lines with bugs to report them", "rules.combo": "Consecutive correct answers for combo bonus", - "rules.skip": "Skip difficult problems" + "rules.skip": "Skip difficult problems", + "ranking.title": "Ranking", + "ranking.period": "Period", + "ranking.period.week": "Last 7 Days", + "ranking.rank": "Rank", + "ranking.player": "Player", + "ranking.score": "Score", + "ranking.accuracy": "Accuracy", + "ranking.date": "Date", + "ranking.codeLanguage": "Code Language", + "ranking.noData": "No data available", + "ranking.anonymous": "Anonymous" } diff --git a/app/locales/ja.json b/app/locales/ja.json index 075f34d..151bad1 100644 --- a/app/locales/ja.json +++ b/app/locales/ja.json @@ -20,5 +20,16 @@ "rules.time": "制限時間は60秒", "rules.tap": "バグがある行をタップして指摘", "rules.combo": "連続正解でコンボボーナス", - "rules.skip": "難しい問題はスキップ可能" + "rules.skip": "難しい問題はスキップ可能", + "ranking.title": "ランキング", + "ranking.period": "期間", + "ranking.period.week": "過去7日間", + "ranking.rank": "順位", + "ranking.player": "プレイヤー", + "ranking.score": "スコア", + "ranking.accuracy": "正確性", + "ranking.date": "日付", + "ranking.codeLanguage": "コード言語", + "ranking.noData": "データがありません", + "ranking.anonymous": "匿名" } diff --git a/app/routes.ts b/app/routes.ts index f709beb..b29aaf2 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -4,6 +4,7 @@ export default [ index('routes/_index.tsx'), route(':lang', 'routes/$lang.tsx'), route(':lang/:codeLanguage/play', 'routes/$lang.$codeLanguage.play.tsx'), + route(':lang/ranking', 'routes/$lang.ranking.tsx'), route('result/create', 'routes/result.create.tsx'), route('result/:id', 'routes/result.$id.tsx'), ] satisfies RouteConfig; diff --git a/app/routes/$lang.ranking.tsx b/app/routes/$lang.ranking.tsx new file mode 100644 index 0000000..b4fc87d --- /dev/null +++ b/app/routes/$lang.ranking.tsx @@ -0,0 +1,238 @@ +import type { Route } from './+types/$lang.ranking'; +import { redirect } from 'react-router'; +import { useLoaderData } from 'react-router'; +import { t, type SupportedLanguage } from '~/locales'; +import { Header } from '~/components/Header'; + +type ScoreRecord = { + id: string; + score: number; + issues_found: number; + total_issues: number; + accuracy: number; + ui_language: string; + code_language: string; + player_name: string | null; + created_at: string; +}; + +export function meta({ params }: Route.MetaArgs) { + const lang = params.lang as SupportedLanguage; + const title = t('ranking.title', lang); + const period = t('ranking.period.week', lang); + + return [{ title: `${title} (${period}) | Bug Sniper` }]; +} + +export async function loader({ params, request, context }: Route.LoaderArgs) { + const lang = params.lang; + + // Validate language parameter + if (lang !== 'ja' && lang !== 'en') { + throw redirect('/'); + } + + const url = new URL(request.url); + const codeLanguage = url.searchParams.get('code') || 'all'; + + const db = context.cloudflare.env.DB; + + if (!db) { + throw new Response('Database not configured', { status: 500 }); + } + + // Get top 50 scores from the last 7 days + let query: string; + let queryParams: string[]; + + if (codeLanguage === 'all') { + query = ` + SELECT * FROM scores + WHERE created_at >= datetime('now', '-7 days') + ORDER BY score DESC + LIMIT 50 + `; + queryParams = []; + } else { + query = ` + SELECT * FROM scores + WHERE code_language = ? + AND created_at >= datetime('now', '-7 days') + ORDER BY score DESC + LIMIT 50 + `; + queryParams = [codeLanguage]; + } + + const result = await db.prepare(query).bind(...queryParams).all(); + + return { + scores: (result.results || []) as ScoreRecord[], + codeLanguage, + lang: lang as SupportedLanguage, + }; +} + +function getCodeLanguageDisplay(code: string): string { + const map: Record = { + all: 'All', + javascript: 'JavaScript', + python: 'Python', + php: 'PHP', + ruby: 'Ruby', + java: 'Java', + dart: 'Dart', + }; + return map[code] || code; +} + +export default function Ranking() { + const { scores, codeLanguage, lang } = useLoaderData(); + + const codeLanguages = ['all', 'javascript', 'python', 'php', 'ruby', 'java', 'dart']; + + const formatDate = (dateStr: string) => { + try { + const date = new Date(dateStr); + return date.toLocaleDateString(lang === 'ja' ? 'ja-JP' : 'en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } + }; + + return ( +
+
+ +
+ {/* Title */} +
+

+ {t('ranking.title', lang)} +

+

+ {t('ranking.period.week', lang)} +

+
+ + {/* Language Filter Tabs */} +
+ {codeLanguages.map((code) => { + const isActive = codeLanguage === code; + const href = `/${lang}/ranking?code=${code}`; + + return ( + + {t(`language.${code}`, lang)} + + ); + })} +
+ + {/* Ranking Table */} + {scores.length === 0 ? ( +
+ {t('ranking.noData', lang)} +
+ ) : ( +
+ + + + + + + + + + + + + {scores.map((score: ScoreRecord, index: number) => { + const rank = index + 1; + const rankClass = + rank === 1 + ? 'text-yellow-600 dark:text-yellow-400 font-bold' + : rank === 2 + ? 'text-slate-400 dark:text-slate-500 font-bold' + : rank === 3 + ? 'text-orange-600 dark:text-orange-400 font-bold' + : ''; + + return ( + + + + + + + + + ); + })} + +
+ {t('ranking.rank', lang)} + + {t('ranking.player', lang)} + + {t('ranking.codeLanguage', lang)} + + {t('ranking.score', lang)} + + {t('ranking.accuracy', lang)} + + {t('ranking.date', lang)} +
+ {rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank} + + + {score.player_name || t('ranking.anonymous', lang)} + + + {getCodeLanguageDisplay(score.code_language)} + + {score.score} + + {(score.accuracy * 100).toFixed(1)}% + + {formatDate(score.created_at)} +
+
+ )} + + {/* Back to Home Button */} + +
+
+ ); +} diff --git a/app/routes/result.$id.tsx b/app/routes/result.$id.tsx index 120afe8..c79933b 100644 --- a/app/routes/result.$id.tsx +++ b/app/routes/result.$id.tsx @@ -244,7 +244,7 @@ export default function Result({ loaderData }: Route.ComponentProps) { {lang === 'ja' ? 'もう一度プレイ' : 'Play Again'} {lang === 'ja' ? 'ランキングを見る' : 'View Ranking'} diff --git a/app/welcome/welcome.tsx b/app/welcome/welcome.tsx index 6c9d69e..c686afa 100644 --- a/app/welcome/welcome.tsx +++ b/app/welcome/welcome.tsx @@ -38,6 +38,13 @@ export function Welcome({ lang }: { lang: SupportedLanguage }) { + + {t('nav.ranking', lang)} + +