From 4ac28189982d08d833c1159493683eb10cc80e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 08:48:31 +0000 Subject: [PATCH 1/3] feat: Implement ranking feature with SSR and i18n support - Add ranking route with loader for SSR - Query top 50 scores from last 7 days from D1 - Add code language filter tabs (all, javascript, python, php, ruby, java, dart) - Add ranking translations for ja and en locales - Display ranking with player name, score, accuracy, and date - Add HTML title with i18n support - Link to individual result pages from ranking --- app/locales/en.json | 13 ++- app/locales/ja.json | 13 ++- app/routes.ts | 1 + app/routes/ranking.tsx | 236 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 app/routes/ranking.tsx 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..9742264 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -6,4 +6,5 @@ export default [ route(':lang/:codeLanguage/play', 'routes/$lang.$codeLanguage.play.tsx'), route('result/create', 'routes/result.create.tsx'), route('result/:id', 'routes/result.$id.tsx'), + route('ranking', 'routes/ranking.tsx'), ] satisfies RouteConfig; diff --git a/app/routes/ranking.tsx b/app/routes/ranking.tsx new file mode 100644 index 0000000..f1737a2 --- /dev/null +++ b/app/routes/ranking.tsx @@ -0,0 +1,236 @@ +import { type LoaderFunctionArgs, type MetaFunction } from 'react-router'; +import { useLoaderData, useLocation } from 'react-router'; +import { t, detectLanguage, 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 const meta: MetaFunction = ({ data, location }) => { + const searchParams = new URLSearchParams(location.search); + const uiLang = (searchParams.get('lang') as SupportedLanguage) || detectLanguage(searchParams); + + const title = t('ranking.title', uiLang); + const period = t('ranking.period.week', uiLang); + + return [{ title: `${title} (${period}) | Bug Sniper` }]; +}; + +export async function loader({ request, context }: LoaderFunctionArgs) { + const url = new URL(request.url); + const codeLanguage = url.searchParams.get('code') || 'all'; + const uiLanguage = url.searchParams.get('lang') || detectLanguage(url.searchParams); + + const db = context.cloudflare.env.DB; + + // Get top 50 scores from the last 7 days + let query: string; + let params: string[]; + + if (codeLanguage === 'all') { + query = ` + SELECT * FROM scores + WHERE created_at >= datetime('now', '-7 days') + ORDER BY score DESC + LIMIT 50 + `; + params = []; + } else { + query = ` + SELECT * FROM scores + WHERE code_language = ? + AND created_at >= datetime('now', '-7 days') + ORDER BY score DESC + LIMIT 50 + `; + params = [codeLanguage]; + } + + const result = await db.prepare(query).bind(...params).all(); + + return { + scores: (result.results || []) as ScoreRecord[], + codeLanguage, + uiLanguage, + }; +} + +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, uiLanguage } = useLoaderData(); + const location = useLocation(); + const lang = uiLanguage as SupportedLanguage; + + 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 searchParams = new URLSearchParams(location.search); + searchParams.set('code', code); + if (lang) { + searchParams.set('lang', lang); + } + const href = `/ranking?${searchParams.toString()}`; + + 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 */} + +
+
+ ); +} From 76c32b5245808855674898a503bb75fe6383d3f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 12:59:20 +0000 Subject: [PATCH 2/3] feat: Add locale support to ranking page and link from home - Move ranking route from /ranking to /:lang/ranking - Update ranking page to use params.lang instead of query params - Add ranking link to home page (Welcome component) - Update result page ranking link to include locale - Remove unused useLocation and query param handling --- app/routes.ts | 2 +- app/routes/{ranking.tsx => $lang.ranking.tsx} | 50 +++++++++---------- app/routes/result.$id.tsx | 2 +- app/welcome/welcome.tsx | 7 +++ 4 files changed, 33 insertions(+), 28 deletions(-) rename app/routes/{ranking.tsx => $lang.ranking.tsx} (84%) diff --git a/app/routes.ts b/app/routes.ts index 9742264..b29aaf2 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -4,7 +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'), - route('ranking', 'routes/ranking.tsx'), ] satisfies RouteConfig; diff --git a/app/routes/ranking.tsx b/app/routes/$lang.ranking.tsx similarity index 84% rename from app/routes/ranking.tsx rename to app/routes/$lang.ranking.tsx index f1737a2..2434a4a 100644 --- a/app/routes/ranking.tsx +++ b/app/routes/$lang.ranking.tsx @@ -1,6 +1,7 @@ -import { type LoaderFunctionArgs, type MetaFunction } from 'react-router'; -import { useLoaderData, useLocation } from 'react-router'; -import { t, detectLanguage, type SupportedLanguage } from '~/locales'; +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 = { @@ -15,26 +16,30 @@ type ScoreRecord = { created_at: string; }; -export const meta: MetaFunction = ({ data, location }) => { - const searchParams = new URLSearchParams(location.search); - const uiLang = (searchParams.get('lang') as SupportedLanguage) || detectLanguage(searchParams); - - const title = t('ranking.title', uiLang); - const period = t('ranking.period.week', uiLang); +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('/'); + } -export async function loader({ request, context }: LoaderFunctionArgs) { const url = new URL(request.url); const codeLanguage = url.searchParams.get('code') || 'all'; - const uiLanguage = url.searchParams.get('lang') || detectLanguage(url.searchParams); const db = context.cloudflare.env.DB; // Get top 50 scores from the last 7 days let query: string; - let params: string[]; + let queryParams: string[]; if (codeLanguage === 'all') { query = ` @@ -43,7 +48,7 @@ export async function loader({ request, context }: LoaderFunctionArgs) { ORDER BY score DESC LIMIT 50 `; - params = []; + queryParams = []; } else { query = ` SELECT * FROM scores @@ -52,15 +57,15 @@ export async function loader({ request, context }: LoaderFunctionArgs) { ORDER BY score DESC LIMIT 50 `; - params = [codeLanguage]; + queryParams = [codeLanguage]; } - const result = await db.prepare(query).bind(...params).all(); + const result = await db.prepare(query).bind(...queryParams).all(); return { scores: (result.results || []) as ScoreRecord[], codeLanguage, - uiLanguage, + lang: lang as SupportedLanguage, }; } @@ -78,9 +83,7 @@ function getCodeLanguageDisplay(code: string): string { } export default function Ranking() { - const { scores, codeLanguage, uiLanguage } = useLoaderData(); - const location = useLocation(); - const lang = uiLanguage as SupportedLanguage; + const { scores, codeLanguage, lang } = useLoaderData(); const codeLanguages = ['all', 'javascript', 'python', 'php', 'ruby', 'java', 'dart']; @@ -117,12 +120,7 @@ export default function Ranking() {
{codeLanguages.map((code) => { const isActive = codeLanguage === code; - const searchParams = new URLSearchParams(location.search); - searchParams.set('code', code); - if (lang) { - searchParams.set('lang', lang); - } - const href = `/ranking?${searchParams.toString()}`; + const href = `/${lang}/ranking?code=${code}`; return ( {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)} + +
From 3909650b6427f39dc655592b526378ded5dcdb19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 13:10:10 +0000 Subject: [PATCH 3/3] fix: Add database existence check in ranking loader --- app/routes/$lang.ranking.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/routes/$lang.ranking.tsx b/app/routes/$lang.ranking.tsx index 2434a4a..b4fc87d 100644 --- a/app/routes/$lang.ranking.tsx +++ b/app/routes/$lang.ranking.tsx @@ -37,6 +37,10 @@ export async function loader({ params, request, context }: Route.LoaderArgs) { 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[];