Skip to content

Commit c7a1ca8

Browse files
committed
Add SBTI personality leaderboard
1 parent fa61f17 commit c7a1ca8

9 files changed

Lines changed: 225 additions & 7 deletions

File tree

app/[locale]/(main)/games/sbti-test/components/SbtiTestGame.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
44
import Image from 'next/image';
55
import { useLocale, useTranslations } from 'next-intl';
66
import { cn } from '@/lib/utils';
7+
import { submitScoreToLeaderboard } from '@/lib/leaderboard';
78
import {
89
DIM_EXPLANATIONS,
910
DRUNK_TRIGGER_QUESTION_ID,
@@ -197,6 +198,11 @@ export default function SbtiTestGame() {
197198
return () => window.clearTimeout(timeoutId);
198199
}, [answers, screen]);
199200

201+
useEffect(() => {
202+
if (screen !== 'result' || !result) return;
203+
void submitScoreToLeaderboard('sbti-test', 1, { mode: result.finalType.code });
204+
}, [result, screen]);
205+
200206
const visibleQuestions = useMemo(() => buildVisibleQuestions(questionDeck, answers), [answers, questionDeck]);
201207
const currentQuestion = visibleQuestions[currentQuestionIndex] ?? null;
202208
const isEnglish = locale === 'en';
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useMemo, useState } from 'react';
4+
import { useLocale, useTranslations } from 'next-intl';
5+
import { TYPE_LIBRARY } from '../data';
6+
import { EN_TYPE_COPY } from '../copy';
7+
8+
type Entry = {
9+
mode: string;
10+
totalSubmissions: number;
11+
};
12+
13+
type ResponseData = {
14+
entries: Entry[];
15+
totalSubmissions: number;
16+
};
17+
18+
export default function SbtiTypeLeaderboard() {
19+
const locale = useLocale();
20+
const t = useTranslations('sbtiTest.leaderboard');
21+
const [entries, setEntries] = useState<Entry[]>([]);
22+
const [loading, setLoading] = useState(true);
23+
24+
const fetchDistribution = useCallback(async () => {
25+
try {
26+
setLoading(true);
27+
const res = await fetch('/api/leaderboard?gameId=sbti-test&aggregate=modeCounts', {
28+
cache: 'no-store',
29+
});
30+
31+
if (!res.ok) {
32+
throw new Error('Failed to fetch leaderboard');
33+
}
34+
35+
const data = await res.json() as ResponseData;
36+
setEntries(data.entries);
37+
} catch (error) {
38+
console.error(error);
39+
setEntries([]);
40+
} finally {
41+
setLoading(false);
42+
}
43+
}, []);
44+
45+
useEffect(() => {
46+
void fetchDistribution();
47+
}, [fetchDistribution]);
48+
49+
useEffect(() => {
50+
const handleUpdate = (event: Event) => {
51+
const customEvent = event as CustomEvent<{ gameId: string }>;
52+
if (customEvent.detail?.gameId === 'sbti-test') {
53+
void fetchDistribution();
54+
}
55+
};
56+
57+
window.addEventListener('leaderboardUpdated', handleUpdate);
58+
return () => window.removeEventListener('leaderboardUpdated', handleUpdate);
59+
}, [fetchDistribution]);
60+
61+
const localizedName = useCallback((code: string) => {
62+
if (locale === 'en') {
63+
return EN_TYPE_COPY[code]?.name || TYPE_LIBRARY[code as keyof typeof TYPE_LIBRARY]?.cn || code;
64+
}
65+
66+
return TYPE_LIBRARY[code as keyof typeof TYPE_LIBRARY]?.cn || code;
67+
}, [locale]);
68+
69+
const rankedEntries = useMemo(() => entries.map((entry) => ({
70+
...entry,
71+
name: localizedName(entry.mode),
72+
})), [entries, localizedName]);
73+
74+
if (loading) {
75+
return (
76+
<div className="w-full bg-background border rounded-xl overflow-hidden shadow-sm">
77+
<div className="flex justify-center p-8 text-muted-foreground animate-pulse">
78+
{t('loading')}
79+
</div>
80+
</div>
81+
);
82+
}
83+
84+
if (rankedEntries.length === 0) {
85+
return (
86+
<div className="w-full bg-background border rounded-xl overflow-hidden shadow-sm">
87+
<div className="p-8 text-center text-muted-foreground">
88+
{t('empty')}
89+
</div>
90+
</div>
91+
);
92+
}
93+
94+
return (
95+
<div className="w-full bg-background border rounded-xl overflow-hidden shadow-sm">
96+
<div className="max-h-[420px] overflow-y-auto">
97+
<table className="w-full text-sm">
98+
<thead className="bg-muted/50 sticky top-0 backdrop-blur-sm z-10">
99+
<tr>
100+
<th className="text-left font-medium p-4 text-muted-foreground">{t('rank')}</th>
101+
<th className="text-left font-medium p-4 text-muted-foreground">{t('type')}</th>
102+
<th className="text-right font-medium p-4 text-muted-foreground">{t('count')}</th>
103+
</tr>
104+
</thead>
105+
<tbody className="divide-y">
106+
{rankedEntries.map((entry, index) => (
107+
<tr key={entry.mode} className="hover:bg-muted/50 transition-colors">
108+
<td className="p-4">
109+
<div className="flex items-center justify-center w-6 h-6 rounded-full font-bold bg-muted text-muted-foreground text-xs">
110+
{index + 1}
111+
</div>
112+
</td>
113+
<td className="p-4 font-medium text-foreground">
114+
{entry.name}
115+
</td>
116+
<td className="p-4 text-right font-mono font-bold text-foreground">
117+
{entry.totalSubmissions}
118+
</td>
119+
</tr>
120+
))}
121+
</tbody>
122+
</table>
123+
</div>
124+
</div>
125+
);
126+
}

app/[locale]/(main)/games/sbti-test/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { GamePageTemplate } from '@/components/GamePageTemplate';
66
import { routing } from '@/i18n/routing';
77
import { generateAlternates } from '@/lib/utils';
88
import Game from './components/Game';
9+
import SbtiTypeLeaderboard from './components/SbtiTypeLeaderboard';
910

1011
export function generateStaticParams() {
1112
return routing.locales.map((locale) => ({ locale }));
@@ -118,6 +119,8 @@ export default function SbtiTestPage({ params }: { params: Promise<{ locale: str
118119
<p>{t('credits.originalAuthor')}</p>
119120
</>
120121
}
122+
leaderboardIntro={<p>{t('leaderboard.description')}</p>}
123+
leaderboardComponent={<SbtiTypeLeaderboard />}
121124
faq={faqItems}
122125
relatedGames={['cps-test', 'stroop-effect-test', 'spacebar-clicker']}
123126
structuredData={structuredData}

app/api/leaderboard/route.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,13 +273,48 @@ export async function GET(req: NextRequest) {
273273
try {
274274
const { searchParams } = new URL(req.url);
275275
const gameId = searchParams.get("gameId");
276+
const aggregate = searchParams.get("aggregate");
276277
const mode = searchParams.get("mode") || DEFAULT_LEADERBOARD_MODE;
277278

278-
if (!gameId || !isValidMode(mode)) {
279+
if (!gameId) {
279280
return NextResponse.json({ error: "Missing or invalid parameters" }, { status: 400 });
280281
}
281282

282283
const { db, bucket } = await getCloudflareBindings();
284+
285+
if (aggregate === "modeCounts") {
286+
const rows = await db.prepare(
287+
`SELECT mode, COUNT(*) AS totalSubmissions
288+
FROM leaderboard
289+
WHERE game_id = ?
290+
AND mode IS NOT NULL
291+
AND mode != ''
292+
GROUP BY mode
293+
ORDER BY totalSubmissions DESC, mode ASC`
294+
).bind(gameId).all();
295+
296+
const entries = rows.results.map((row) => ({
297+
mode: String(row.mode ?? ""),
298+
totalSubmissions: toNumber(row.totalSubmissions),
299+
}));
300+
301+
return NextResponse.json(
302+
{
303+
entries,
304+
totalSubmissions: entries.reduce((sum, entry) => sum + entry.totalSubmissions, 0),
305+
},
306+
{
307+
headers: {
308+
"Cache-Control": "public, max-age=30, s-maxage=30, stale-while-revalidate=120",
309+
},
310+
}
311+
);
312+
}
313+
314+
if (!isValidMode(mode)) {
315+
return NextResponse.json({ error: "Missing or invalid parameters" }, { status: 400 });
316+
}
317+
283318
let snapshot = await readSnapshot(bucket, gameId, mode);
284319

285320
if (!snapshot) {

components/GamePageTemplate.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ interface GamePageTemplateProps {
5555
leaderboardFormatterType?: FormatterType;
5656
leaderboardMode?: string;
5757
leaderboardIntro?: React.ReactNode;
58+
leaderboardComponent?: React.ReactNode;
5859
structuredData?: Record<string, unknown> | Array<Record<string, unknown>>;
5960
}
6061

@@ -73,6 +74,7 @@ export function GamePageTemplate({
7374
leaderboardFormatterType,
7475
leaderboardMode,
7576
leaderboardIntro,
77+
leaderboardComponent,
7678
structuredData
7779
}: GamePageTemplateProps) {
7880
const t = useTranslations('common');
@@ -130,7 +132,7 @@ export function GamePageTemplate({
130132
</section>
131133

132134
{/* Leaderboard - Option */}
133-
{hasLeaderboard && (
135+
{(hasLeaderboard || leaderboardComponent) && (
134136
<section className="max-w-6xl mx-auto mb-16 space-y-6">
135137
<div className="space-y-2">
136138
<h2 className="text-3xl font-bold text-center">
@@ -143,11 +145,13 @@ export function GamePageTemplate({
143145
)}
144146
</div>
145147
<div className="rounded-lg">
146-
<LeaderboardDisplay
147-
gameId={gameId}
148-
formatterType={leaderboardFormatterType}
149-
mode={leaderboardMode}
150-
/>
148+
{leaderboardComponent ?? (
149+
<LeaderboardDisplay
150+
gameId={gameId}
151+
formatterType={leaderboardFormatterType}
152+
mode={leaderboardMode}
153+
/>
154+
)}
151155
</div>
152156
</section>
153157
)}

messages/en/compiled.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3233,6 +3233,17 @@
32333233
"originalAuthor": "Original concept credit: Bilibili creator @蛆肉儿串儿",
32343234
"sourceCode": "Source mirror on GitHub"
32353235
},
3236+
"leaderboard": {
3237+
"description": "See the current SBTI personality ranking and which result appears most often.",
3238+
"loading": "Counting which personalities people are getting...",
3239+
"empty": "Not enough results yet. Be the first to add one.",
3240+
"rank": "Rank",
3241+
"type": "Type",
3242+
"count": "Count",
3243+
"topLabel": "Current leader",
3244+
"topDescription": "This type is currently in first place with {count} submissions, out of {total} total recorded results.",
3245+
"totalSubmissions": "Total submissions"
3246+
},
32363247
"gameUI": {
32373248
"introEyebrow": "Trending personality quiz",
32383249
"introTitle": "MBTI is old news. SBTI is here.",

messages/en/sbtiTest.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@
8585
"originalAuthor": "Original concept credit: Bilibili creator @蛆肉儿串儿",
8686
"sourceCode": "Source mirror on GitHub"
8787
},
88+
"leaderboard": {
89+
"description": "See the current SBTI personality ranking and which result appears most often.",
90+
"loading": "Counting which personalities people are getting...",
91+
"empty": "Not enough results yet. Be the first to add one.",
92+
"rank": "Rank",
93+
"type": "Type",
94+
"count": "Count",
95+
"topLabel": "Current leader",
96+
"topDescription": "This type is currently in first place with {count} submissions, out of {total} total recorded results.",
97+
"totalSubmissions": "Total submissions"
98+
},
8899
"gameUI": {
89100
"introEyebrow": "Trending personality quiz",
90101
"introTitle": "MBTI is old news. SBTI is here.",

messages/zh/compiled.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3233,6 +3233,17 @@
32333233
"originalAuthor": "原始创意作者:B站 @蛆肉儿串儿",
32343234
"sourceCode": "GitHub 镜像源码"
32353235
},
3236+
"leaderboard": {
3237+
"description": "查看当前 SBTI 人格排行榜,看看哪种人格结果最多。",
3238+
"loading": "正在统计大家都测成了什么人格...",
3239+
"empty": "还没有足够的数据,先去做一次测试吧。",
3240+
"rank": "排名",
3241+
"type": "人格",
3242+
"count": "次数",
3243+
"topLabel": "当前最多",
3244+
"topDescription": "{count} 次提交里,它目前排在第一。总共已经记录了 {total} 次结果。",
3245+
"totalSubmissions": "总提交次数"
3246+
},
32363247
"gameUI": {
32373248
"introEyebrow": "最近很火的人格测试",
32383249
"introTitle": "MBTI 已经过时,SBTI 来了。",

messages/zh/sbtiTest.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@
8585
"originalAuthor": "原始创意作者:B站 @蛆肉儿串儿",
8686
"sourceCode": "GitHub 镜像源码"
8787
},
88+
"leaderboard": {
89+
"description": "查看当前 SBTI 人格排行榜,看看哪种人格结果最多。",
90+
"loading": "正在统计大家都测成了什么人格...",
91+
"empty": "还没有足够的数据,先去做一次测试吧。",
92+
"rank": "排名",
93+
"type": "人格",
94+
"count": "次数",
95+
"topLabel": "当前最多",
96+
"topDescription": "{count} 次提交里,它目前排在第一。总共已经记录了 {total} 次结果。",
97+
"totalSubmissions": "总提交次数"
98+
},
8899
"gameUI": {
89100
"introEyebrow": "最近很火的人格测试",
90101
"introTitle": "MBTI 已经过时,SBTI 来了。",

0 commit comments

Comments
 (0)