feat: add realtime Discord leaderboards

This commit is contained in:
2026-07-25 00:06:52 +07:00 Unverified
parent 7a9e67e283
commit bb6740fcee
39 changed files with 1917 additions and 119 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import { submissions, answers } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
import { sse } from "@/lib/sse";
import {
+1 -1
View File
@@ -2,7 +2,7 @@ import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AdminSidebar } from "./client";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
+1 -1
View File
@@ -1,5 +1,5 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { redirect } from "next/navigation";
import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth";
+2 -48
View File
@@ -1,51 +1,5 @@
import NextAuth, { type NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { accounts, sessions, users, verificationTokens } from "@/db/schema";
import type { SessionWithDiscord } from "@/lib/auth";
const discordIdCache = new Map<string, string>();
export const authOptions: NextAuthOptions = {
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
if (session.user) {
(session as SessionWithDiscord).user!.id = user.id;
let discordId = discordIdCache.get(user.id);
if (!discordId) {
const acct = await db.query.accounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.userId, user.id), eq(a.provider, "discord")),
columns: { providerAccountId: true },
});
if (acct) {
discordId = acct.providerAccountId;
discordIdCache.set(user.id, discordId);
}
}
if (discordId) {
(session as SessionWithDiscord).user!.discordId = discordId;
}
}
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+1 -1
View File
@@ -1,7 +1,7 @@
import { db } from "@/db";
import { FormFillerClient } from "../client";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { notFound } from "next/navigation";
import LoginDialog from "@/components/login-dialog";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
+1 -1
View File
@@ -1,5 +1,5 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { FormLayoutClient, LoginButton } from "./client";
import { Metadata } from "next";
import { ThemeToggle } from "@/components/theme-toggle";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from "@/components/ui/card";
import { HtmlDisplay } from "@/components/html-display";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
import { RealtimeRefresh } from "@/components/realtime-refresh";
+18
View File
@@ -0,0 +1,18 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function LeaderboardLoading() {
return (
<main className="dark min-h-screen bg-background px-4 py-6 text-foreground sm:px-6 lg:py-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<Skeleton className="h-9 w-36" />
<Skeleton className="h-48 w-full rounded-2xl" />
<div className="grid gap-3 md:grid-cols-3">
<Skeleton className="h-36 w-full rounded-2xl" />
<Skeleton className="h-36 w-full rounded-2xl" />
<Skeleton className="h-36 w-full rounded-2xl" />
</div>
<Skeleton className="h-96 w-full rounded-2xl" />
</div>
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export function GET(request: Request) {
return NextResponse.redirect(new URL("/leaderboard/xp", request.url), 307);
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ChannelSelect } from "@/components/leaderboard/channel-select";
import { LeaderboardBoard } from "@/components/leaderboard/leaderboard-board";
import { LeaderboardError } from "@/components/leaderboard/leaderboard-error";
import { LeaderboardShell } from "@/components/leaderboard/leaderboard-shell";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getVoiceLeaderboardPage } from "@/lib/leaderboard/database";
import { getCurrentDiscordVoiceChannels } from "@/lib/leaderboard/discord";
import { normalizeRequestedPage } from "@/lib/leaderboard/ranking";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "อันดับเวลาในห้องเสียง",
description: "ดูอันดับเวลารวมในห้องเสียงและห้องเวที Discord ปัจจุบันของ Erika!",
alternates: {
canonical: "/leaderboard/vc",
},
};
type VoiceLeaderboardPageProps = {
searchParams: Promise<{
channel?: string | string[];
page?: string | string[];
}>;
};
function voiceLeaderboardUrl(page: number, channelId?: string) {
const params = new URLSearchParams();
if (channelId) params.set("channel", channelId);
if (page > 1) params.set("page", String(page));
const query = params.toString();
return query ? `/leaderboard/vc?${query}` : "/leaderboard/vc";
}
export default async function VoiceLeaderboardPage({
searchParams,
}: VoiceLeaderboardPageProps) {
const query = await searchParams;
const requestedPage = normalizeRequestedPage(query.page);
let channels;
try {
channels = await getCurrentDiscordVoiceChannels();
} catch (error) {
console.error("Failed to load Discord voice channels", error);
return (
<LeaderboardShell
active="vc"
title="อันดับเวลาในห้องเสียง"
description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้"
>
<LeaderboardError source="discord" />
</LeaderboardShell>
);
}
const requestedChannelId = typeof query.channel === "string"
? query.channel
: undefined;
const selectedChannelId = channels.some(
(channel) => channel.id === requestedChannelId,
)
? requestedChannelId
: undefined;
const invalidChannel = query.channel !== undefined && !selectedChannelId;
let data;
try {
data = await getVoiceLeaderboardPage(
channels.map((channel) => channel.id),
requestedPage,
selectedChannelId,
);
} catch (error) {
console.error("Failed to load voice leaderboard", error);
return (
<LeaderboardShell
active="vc"
title="อันดับเวลาในห้องเสียง"
description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้"
>
<ChannelSelect
channels={channels}
selectedChannelId={selectedChannelId}
/>
<LeaderboardError source="database" />
</LeaderboardShell>
);
}
if (
invalidChannel
|| (query.page !== undefined && query.page !== String(data.page))
) {
redirect(voiceLeaderboardUrl(data.page, selectedChannelId));
}
return (
<LeaderboardShell
active="vc"
title="อันดับเวลาในห้องเสียง"
description="รวมเวลาจากห้องเสียงและห้องเวทีที่ยังอยู่ใน Discord ตอนนี้ หรือเลือกดูเฉพาะห้องได้"
>
<RealtimeRefresh
topic="leaderboards"
leaderboard="vc"
channelId={selectedChannelId}
/>
<ChannelSelect
channels={channels}
selectedChannelId={selectedChannelId}
/>
<LeaderboardBoard
kind="vc"
data={data}
channelId={selectedChannelId}
/>
</LeaderboardShell>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { LeaderboardBoard } from "@/components/leaderboard/leaderboard-board";
import { LeaderboardError } from "@/components/leaderboard/leaderboard-error";
import { LeaderboardShell } from "@/components/leaderboard/leaderboard-shell";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getXpLeaderboardPage } from "@/lib/leaderboard/database";
import { normalizeRequestedPage } from "@/lib/leaderboard/ranking";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "อันดับ XP",
description: "ดูอันดับ XP รวมและเลเวลของสมาชิกใน Discord ของ Erika!",
alternates: {
canonical: "/leaderboard/xp",
},
};
type XpLeaderboardPageProps = {
searchParams: Promise<{
page?: string | string[];
}>;
};
export default async function XpLeaderboardPage({
searchParams,
}: XpLeaderboardPageProps) {
const query = await searchParams;
const requestedPage = normalizeRequestedPage(query.page);
let data;
try {
data = await getXpLeaderboardPage(requestedPage);
} catch (error) {
console.error("Failed to load XP leaderboard", error);
return (
<LeaderboardShell
active="xp"
title="อันดับ XP"
description="จัดอันดับจาก XP รวมของสมาชิกที่มีเลเวล 1 ขึ้นไป อันดับที่มี XP เท่ากันจะแชร์อันดับเดียวกัน"
>
<LeaderboardError source="database" />
</LeaderboardShell>
);
}
if (query.page !== undefined && query.page !== String(data.page)) {
redirect(data.page > 1 ? `/leaderboard/xp?page=${data.page}` : "/leaderboard/xp");
}
return (
<LeaderboardShell
active="xp"
title="อันดับ XP"
description="จัดอันดับจาก XP รวมของสมาชิกที่มีเลเวล 1 ขึ้นไป อันดับที่มี XP เท่ากันจะแชร์อันดับเดียวกัน"
>
<RealtimeRefresh topic="leaderboards" leaderboard="xp" />
<LeaderboardBoard kind="xp" data={data} />
</LeaderboardShell>
);
}
+12
View File
@@ -22,5 +22,17 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "yearly",
priority: 0.5,
},
{
url: `${baseUrl}/leaderboard/xp`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${baseUrl}/leaderboard/vc`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
];
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import {
getAdminDiscordIds,
getSessionDiscordId,
+2 -1
View File
@@ -5,7 +5,7 @@ import type { ComponentType, SVGProps } from "react";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import Link from "next/link";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { ArrowLeft, ArrowRight, Trophy } from "lucide-react";
import { LinkConfig, type IconName } from "@/lib/config";
import { getCountForLink, formatCount, FollowerCounts } from "@/lib/followers/utils";
import { RobloxIcon, DiscordIcon, TiktokIcon, YoutubeIcon, FormIcon, DonateIcon } from "@/lib/icons";
@@ -17,6 +17,7 @@ const iconMap: Record<IconName, ComponentType<SVGProps<SVGSVGElement>>> = {
youtube: YoutubeIcon,
form: FormIcon,
donate: DonateIcon,
leaderboard: Trophy,
};
export function LinkCard({ counts, links1, links2 }: { counts: FollowerCounts, links1: LinkConfig[], links2: LinkConfig[] }) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
import { AdminShell } from "@/components/admin-shell";
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useRouter } from "next/navigation";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Field, FieldLabel } from "@/components/ui/field";
import type { DiscordVoiceChannel } from "@/lib/leaderboard/discord";
const ALL_CHANNELS = "all";
export function ChannelSelect({
channels,
selectedChannelId,
}: {
channels: DiscordVoiceChannel[];
selectedChannelId?: string;
}) {
const router = useRouter();
return (
<Field className="max-w-md">
<FieldLabel htmlFor="voice-channel">ห้องเสียง</FieldLabel>
<Select
value={selectedChannelId ?? ALL_CHANNELS}
onValueChange={(value) => {
const params = new URLSearchParams();
if (value !== ALL_CHANNELS) params.set("channel", value);
const query = params.toString();
router.push(query ? `/leaderboard/vc?${query}` : "/leaderboard/vc");
}}
>
<SelectTrigger id="voice-channel" className="w-full bg-card!">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={ALL_CHANNELS}>ทุกห้องเสียงปัจจุบัน</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.type === 13 ? "เวที: " : ""}
{channel.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
);
}
@@ -0,0 +1,189 @@
import { Trophy } from "lucide-react";
import { LeaderboardPagination } from "@/components/leaderboard/leaderboard-pagination";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
formatInteger,
formatVoiceDuration,
resolveLeaderboardName,
shortenDiscordId,
type LeaderboardPage,
type VoiceLeaderboardRow,
type XpLeaderboardRow,
} from "@/lib/leaderboard/ranking";
type LeaderboardBoardProps =
| {
kind: "xp";
data: LeaderboardPage<XpLeaderboardRow>;
channelId?: never;
}
| {
kind: "vc";
data: LeaderboardPage<VoiceLeaderboardRow>;
channelId?: string;
};
function rankVariant(rank: number) {
if (rank === 1) return "default";
if (rank === 2) return "secondary";
return "outline";
}
function Identity({
displayName,
username,
userId,
}: {
displayName: string | null;
username: string | null;
userId: string;
}) {
const unresolved = !displayName?.trim() && !username?.trim();
return (
<div className="flex min-w-40 flex-col gap-0.5">
<span className="font-medium">
{resolveLeaderboardName({ displayName, username, userId })}
</span>
<span className="text-xs text-muted-foreground">
{unresolved
? `Discord ID: ${shortenDiscordId(userId)}`
: displayName?.trim() && username?.trim()
? `@${username}`
: `Discord ID: ${shortenDiscordId(userId)}`}
</span>
</div>
);
}
function Podium({ data }: LeaderboardBoardProps) {
if (data.podium.length === 0) return null;
return (
<section aria-labelledby="podium-title" className="flex flex-col gap-3">
<h2 id="podium-title" className="text-lg font-medium">สามอันดับนำ</h2>
<div className="grid gap-3 md:grid-cols-3">
{data.podium.map((row) => (
<Card
key={row.userId}
size="sm"
className={row.rank === 1 ? "md:-translate-y-1" : undefined}
>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span className="truncate">
{resolveLeaderboardName(row)}
</span>
<Badge variant={rankVariant(row.rank)}>
{row.rank === 1 && <Trophy data-icon="inline-start" />}
#{row.rank}
</Badge>
</CardTitle>
<CardDescription>
{"level" in row
? `เลเวล ${row.level}`
: "เวลารวมในห้องเสียง"}
</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold tabular-nums">
{"totalXp" in row
? `${formatInteger(row.totalXp)} XP`
: formatVoiceDuration(row.totalSeconds)}
</CardContent>
</Card>
))}
</div>
</section>
);
}
export function LeaderboardBoard(props: LeaderboardBoardProps) {
const { kind, data, channelId } = props;
const firstRow = data.totalRows === 0
? 0
: (data.page - 1) * data.pageSize + 1;
const lastRow = Math.min(data.page * data.pageSize, data.totalRows);
return (
<>
<Podium {...props} />
<Card>
<CardHeader>
<CardTitle>
{kind === "xp" ? "อันดับ XP ทั้งหมด" : "อันดับเวลาในห้องเสียง"}
</CardTitle>
<CardDescription>
{data.totalRows > 0
? `แสดง ${firstRow}–${lastRow} จาก ${formatInteger(String(data.totalRows))} คน`
: "ยังไม่มีข้อมูลในขอบเขตนี้"}
</CardDescription>
</CardHeader>
<CardContent className="px-0">
<Table>
<TableCaption>
{kind === "xp"
? "ตารางอันดับจาก XP รวมของสมาชิกเลเวล 1 ขึ้นไป"
: "ตารางอันดับจากเวลารวมในห้องเสียงและห้องเวทีปัจจุบัน"}
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="pl-6">อันดับ</TableHead>
<TableHead>สมาชิก</TableHead>
{kind === "xp" && <TableHead>เลเวล</TableHead>}
<TableHead className="pr-6 text-right">
{kind === "xp" ? "XP รวม" : "เวลารวม"}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.rows.map((row) => (
<TableRow key={row.userId}>
<TableCell className="pl-6">
<Badge variant={rankVariant(row.rank)}>#{row.rank}</Badge>
</TableCell>
<TableCell>
<Identity {...row} />
</TableCell>
{"level" in row && (
<TableCell className="tabular-nums">{row.level}</TableCell>
)}
<TableCell className="pr-6 text-right font-medium tabular-nums">
{"totalXp" in row
? formatInteger(row.totalXp)
: formatVoiceDuration(row.totalSeconds)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<LeaderboardPagination
path={`/leaderboard/${kind}`}
page={data.page}
totalPages={data.totalPages}
channelId={channelId}
/>
</CardFooter>
</Card>
</>
);
}
@@ -0,0 +1,33 @@
import { DatabaseZap, RadioTower } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function LeaderboardError({ source }: { source: "database" | "discord" }) {
const Icon = source === "database" ? DatabaseZap : RadioTower;
return (
<Card role="alert">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon />
{source === "database"
? "โหลดข้อมูลอันดับไม่สำเร็จ"
: "โหลดรายชื่อห้อง Discord ไม่สำเร็จ"}
</CardTitle>
<CardDescription>
{source === "database"
? "ฐานข้อมูลอันดับยังไม่พร้อมใช้งาน ลองเปิดหน้านี้ใหม่อีกครั้ง"
: "ยังเลือกขอบเขตห้องเสียงไม่ได้ ลองเปิดหน้านี้ใหม่เมื่อ Discord พร้อมใช้งาน"}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
หน้านี้ไม่มีการดึงข้อมูลซ้ำอัตโนมัติระหว่างที่ระบบขัดข้อง
</CardContent>
</Card>
);
}
@@ -0,0 +1,85 @@
import { Fragment } from "react";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
function visiblePages(page: number, totalPages: number) {
const pages = new Set([1, totalPages, page - 1, page, page + 1]);
return [...pages]
.filter((value) => value >= 1 && value <= totalPages)
.sort((a, b) => a - b);
}
function pageHref(path: string, page: number, channelId?: string) {
const params = new URLSearchParams();
if (channelId) params.set("channel", channelId);
if (page > 1) params.set("page", String(page));
const query = params.toString();
return query ? `${path}?${query}` : path;
}
export function LeaderboardPagination({
path,
page,
totalPages,
channelId,
}: {
path: string;
page: number;
totalPages: number;
channelId?: string;
}) {
if (totalPages <= 1) return null;
const pages = visiblePages(page, totalPages);
return (
<Pagination aria-label="หน้าอันดับ">
<PaginationContent>
{page > 1 && (
<PaginationItem>
<PaginationPrevious
href={pageHref(path, page - 1, channelId)}
text="ก่อนหน้า"
aria-label="ไปหน้าก่อนหน้า"
/>
</PaginationItem>
)}
{pages.map((value, index) => (
<Fragment key={value}>
{index > 0 && value - pages[index - 1] > 1 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
href={pageHref(path, value, channelId)}
isActive={value === page}
aria-label={`ไปหน้า ${value}`}
>
{value}
</PaginationLink>
</PaginationItem>
</Fragment>
))}
{page < totalPages && (
<PaginationItem>
<PaginationNext
href={pageHref(path, page + 1, channelId)}
text="ถัดไป"
aria-label="ไปหน้าถัดไป"
/>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
);
}
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { LeaderboardTabs } from "@/components/leaderboard/leaderboard-tabs";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Background from "@/public/background/space.webp";
type LeaderboardShellProps = {
active: "xp" | "vc";
title: string;
description: string;
children: ReactNode;
};
export function LeaderboardShell({
active,
title,
description,
children,
}: LeaderboardShellProps) {
return (
<main className="dark relative min-h-screen overflow-hidden bg-background text-foreground">
<div className="pointer-events-none fixed inset-0" aria-hidden>
<Image
src={Background}
alt=""
fill
priority
sizes="100vw"
className="object-cover opacity-40"
/>
</div>
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6 lg:py-10">
<nav aria-label="ทางลัด" className="flex items-center">
<Button variant="ghost" asChild>
<Link href="/">
<ArrowLeft data-icon="inline-start" />
กลับหน้าหลัก
</Link>
</Button>
</nav>
<Card>
<CardHeader>
<CardTitle>
<h1 className="text-2xl font-semibold sm:text-3xl">{title}</h1>
</CardTitle>
<CardDescription className="max-w-2xl">{description}</CardDescription>
</CardHeader>
<CardContent>
<LeaderboardTabs active={active} />
</CardContent>
</Card>
{children}
</div>
</main>
);
}
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export function LeaderboardTabs({ active }: { active: "xp" | "vc" }) {
const router = useRouter();
return (
<Tabs
value={active}
onValueChange={(value) => router.push(`/leaderboard/${value}`)}
>
<TabsList className="w-full sm:w-fit" aria-label="เลือกประเภทลีดเดอร์บอร์ด">
<TabsTrigger value="xp">อันดับ XP</TabsTrigger>
<TabsTrigger value="vc">เวลาในห้องเสียง</TabsTrigger>
</TabsList>
</Tabs>
);
}
+54 -37
View File
@@ -1,55 +1,72 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { sse, type SseTopic } from "@/lib/sse";
import { sse } from "@/lib/sse";
import {
startRealtimeRefresh,
type RealtimeRefreshHandlers,
} from "@/lib/realtime-refresh";
type RealtimeRefreshProps = {
topic: SseTopic;
type FormRealtimeRefreshProps = {
topic: "forms" | "submissions";
formId?: string;
debounceMs?: number;
};
export function RealtimeRefresh({
topic,
formId,
debounceMs = 150,
}: RealtimeRefreshProps) {
type LeaderboardRealtimeRefreshProps =
| {
topic: "leaderboards";
leaderboard: "xp";
channelId?: never;
debounceMs?: number;
}
| {
topic: "leaderboards";
leaderboard: "vc";
channelId?: string;
debounceMs?: number;
};
type RealtimeRefreshProps =
| FormRealtimeRefreshProps
| LeaderboardRealtimeRefreshProps;
export function RealtimeRefresh(props: RealtimeRefreshProps) {
const router = useRouter();
const connectedOnce = useRef(false);
const topic = props.topic;
const formId = topic === "leaderboards" ? undefined : props.formId;
const leaderboard = topic === "leaderboards" ? props.leaderboard : undefined;
const channelId = topic === "leaderboards" ? props.channelId : undefined;
const debounceMs = props.debounceMs ?? (topic === "leaderboards" ? 500 : 150);
useEffect(() => {
connectedOnce.current = false;
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const filter = topic === "leaderboards"
? leaderboard === "xp"
? { kind: "xp" as const }
: { kind: "vc" as const, channelId }
: { kind: "form" as const, formId };
const refresh = () => {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => router.refresh(), debounceMs);
};
return startRealtimeRefresh({
filter,
debounceMs,
refresh: () => router.refresh(),
subscribe(handlers: RealtimeRefreshHandlers) {
const options = { onopen: handlers.onOpen };
const onopen = () => {
if (connectedOnce.current) {
refresh();
} else {
connectedOnce.current = true;
if (topic === "forms") {
return sse.forms.sub("update", handlers.onForm, options);
}
};
const handleUpdate = (event: { formId: string }) => {
if (!formId || event.formId === formId) {
refresh();
if (topic === "submissions") {
return sse.submissions.sub("update", handlers.onForm, options);
}
};
const subscription = topic === "forms"
? sse.forms.sub("update", handleUpdate, { onopen })
: sse.submissions.sub("update", handleUpdate, { onopen });
return () => {
if (refreshTimer) clearTimeout(refreshTimer);
subscription.clean();
};
}, [debounceMs, formId, router, topic]);
return sse.leaderboards.subMany({
xp: handlers.onXp,
vc: handlers.onVc,
}, options);
},
});
}, [channelId, debounceMs, formId, leaderboard, router, topic]);
return null;
}
+130
View File
@@ -0,0 +1,130 @@
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<typeof Link>
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
asChild
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
>
<Link
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
</Button>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-2!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-2!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-9 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-4xl p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+44
View File
@@ -0,0 +1,44 @@
# ADR 0002: Real-time leaderboard read model
- Status: Accepted
- Date: 2026-07-24
## Context
The Discord bot is the authoritative writer for member XP and cumulative voice time.
The website needs public, paginated leaderboards that update while open without
coupling the form database, migrations, or website write paths to the bot database.
Voice history also contains channels that may no longer exist in the Discord guild.
## Decision
1. The website uses a separate Drizzle/Postgres.js module configured only through
`LEADERBOARD_DATABASE_URL`. Its client starts read-only sessions, uses a small
connection pool and short timeouts, and is not exported. The normal `DATABASE_URL`,
schema barrel, Drizzle configuration, and migrations remain unchanged.
2. Leaderboard database reads are uncached. A Server Component reads the current
values on navigation and after `router.refresh()`. Discord voice and Stage channel
metadata may remain in memory for five minutes.
3. The XP board includes stored level 1+ users and ranks cumulative XP descending.
The voice board sums time across current Discord voice and Stage channels, or one
selected current channel. Historical/deleted channels are excluded.
4. Both boards use shared competition ranks: equal values share a rank and the next
rank skips the tied positions. Equal-value rows use name and Discord ID for stable
display ordering.
5. The existing Redis-backed SSE route gains one public `leaderboards` topic. The bot
publishes minimal XP or channel-scoped VC events only after its database
transaction commits. The browser validates events, filters them to the visible
board, debounces refreshes, and refreshes again after reconnecting.
6. Redis and SSE are notification paths, not the source of truth. A transport failure
does not make the page unusable; navigation still performs a fresh database read.
## Consequences
- The bot remains the only process allowed to mutate leaderboard tables.
- A website database credential should also be restricted to read-only access at the
PostgreSQL role level; the client session setting adds defense in depth.
- Voice totals intentionally change when channels are created or removed because the
default scope is the guild’s current voice surface.
- Open pages update quickly without polling, while reconnection and normal navigation
recover missed notifications.
- Discord channel lookup and leaderboard database failures can be reported separately.
+29
View File
@@ -34,3 +34,32 @@ non-empty text, even when the overall question is optional.
A form that no longer accepts new submissions or updates. A person with an existing
submission may still read or delete it.
## XP leaderboard
A public ranking of stored level 1+ members by cumulative XP.
## VC leaderboard
A public ranking of cumulative voice time across the guild’s current voice and Stage
channels, or one selected current channel.
## Current channel
A Discord voice or Stage channel returned by the guild channel API at the time its
five-minute metadata cache is filled. Deleted and historical channels are not current.
## Shared rank
A competition ranking in which equal values receive the same position and the next
position skips the tied places, such as 1, 2, 2, 4.
## Leaderboard read model
The website’s uncached, read-only view of the bot-owned `user_levels` and `voice_time`
tables, combined with current Discord channel metadata.
## Leaderboard SSE event
A minimal Redis notification on `erika:sse:leaderboards` that tells an open XP or VC
page to refresh its Server Component data after a bot transaction commits.
+61
View File
@@ -0,0 +1,61 @@
# Leaderboard bot integration
The Discord bot owns all writes to `user_levels` and `voice_time`. The website only
subscribes to notifications and then reads the committed database state.
## Redis channel
Publish leaderboard notifications to exactly:
```text
erika:sse:leaderboards
```
The bot and website use the same Redis deployment. Do not add Redis credentials to
source code or event payloads.
## Event envelopes
XP changes use this exact JSON envelope:
```json
{"event":"xp","data":{}}
```
Voice-time changes use the affected Discord channel ID:
```json
{"event":"vc","data":{"channelId":"123456789012345678"}}
```
No additional envelope or data fields are accepted. `channelId` is a Discord
snowflake represented as a decimal string.
## Publish ordering
1. Start and complete the bot’s database transaction.
2. Confirm that the transaction committed successfully.
3. Publish the corresponding Redis event or events.
Never publish before commit, from a rollback path, or from a `finally` block. The
notification tells an open website page to read the database again, so publishing
before commit can produce a stale refresh.
## Event cardinality
- Publish one XP event when one transaction changes XP data.
- Publish one VC event per affected channel when one transaction changes voice time.
- When one transaction changes both XP and voice time, publish one XP event and one VC
event for each affected channel.
- For a bulk transaction, deduplicate affected scopes and channel IDs. Publish per
affected scope/channel, never per user.
Example: a bulk transaction changes XP for 200 users and voice time in channels `111`
and `222`. After commit, publish three events: one XP event, one VC event for `111`,
and one VC event for `222`.
## Error handling
Publishing is best-effort. Log Redis failures with enough context to identify the
event scope, but do not roll back or report a successfully committed database
transaction as failed. Open pages recover on SSE reconnection or the next navigation.
+48
View File
@@ -0,0 +1,48 @@
import type { NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { accounts, sessions, users, verificationTokens } from "@/db/schema";
import type { SessionWithDiscord } from "@/lib/auth";
const discordIdCache = new Map<string, string>();
export const authOptions: NextAuthOptions = {
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
if (session.user) {
(session as SessionWithDiscord).user!.id = user.id;
let discordId = discordIdCache.get(user.id);
if (!discordId) {
const account = await db.query.accounts.findFirst({
where: (record, { eq, and }) =>
and(eq(record.userId, user.id), eq(record.provider, "discord")),
columns: { providerAccountId: true },
});
if (account) {
discordId = account.providerAccountId;
discordIdCache.set(user.id, discordId);
}
}
if (discordId) {
(session as SessionWithDiscord).user!.discordId = discordId;
}
}
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import type { Session } from "next-auth";
import { db } from "@/db";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { authOptions } from "@/lib/auth-options";
import { getGuildMemberRoles } from "@/lib/discord";
export type SessionWithDiscord = Session & {
+7 -1
View File
@@ -1,4 +1,4 @@
export type IconName = "roblox" | "discord" | "tiktok" | "youtube" | "form" | "donate";
export type IconName = "roblox" | "discord" | "tiktok" | "youtube" | "form" | "donate" | "leaderboard";
export interface LinkConfig {
name: string;
@@ -61,6 +61,12 @@ export const config_p2 = {
link: "/form",
desc: "Custom Form ของช่อง erika",
icon: "form",
},
{
name: "Leaderboard",
link: "/leaderboard/xp",
desc: "อันดับ XP และเวลาในห้องเสียง",
icon: "leaderboard",
}
] as LinkConfig[]
};
+112
View File
@@ -0,0 +1,112 @@
import "server-only";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { integer, numeric, pgTable, text } from "drizzle-orm/pg-core";
import postgres from "postgres";
import {
buildVoiceLeaderboard,
buildXpLeaderboard,
type VoiceSourceRow,
} from "@/lib/leaderboard/ranking";
const userLevels = pgTable("user_levels", {
userId: text("user_id").notNull(),
username: text("username").notNull(),
displayName: text("display_name"),
xp: numeric("xp", { precision: 40, scale: 0 }).notNull(),
level: integer("level").notNull(),
});
const voiceTime = pgTable("voice_time", {
userId: text("user_id").notNull(),
channelId: text("channel_id").notNull(),
totalSeconds: numeric("total_seconds", { precision: 40, scale: 0 }).notNull(),
});
type LeaderboardClient = ReturnType<typeof postgres>;
const globalForLeaderboard = globalThis as typeof globalThis & {
erikaLeaderboardReadOnlyClient?: LeaderboardClient;
};
function getLeaderboardDatabase() {
const connectionString = process.env.LEADERBOARD_DATABASE_URL;
if (!connectionString) {
throw new Error("LEADERBOARD_DATABASE_URL is not configured.");
}
if (!globalForLeaderboard.erikaLeaderboardReadOnlyClient) {
globalForLeaderboard.erikaLeaderboardReadOnlyClient = postgres(connectionString, {
max: 3,
idle_timeout: 20,
connect_timeout: 5,
connection: {
application_name: "erika-leaderboard",
default_transaction_read_only: true,
statement_timeout: 15_000,
},
});
}
return drizzle(globalForLeaderboard.erikaLeaderboardReadOnlyClient);
}
export async function getXpLeaderboardPage(requestedPage: number) {
const rows = await getLeaderboardDatabase()
.select({
userId: userLevels.userId,
username: userLevels.username,
displayName: userLevels.displayName,
level: userLevels.level,
totalXp: userLevels.xp,
})
.from(userLevels)
.where(gte(userLevels.level, 1));
return buildXpLeaderboard(rows, requestedPage);
}
export async function getVoiceLeaderboardPage(
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
) {
const scopedChannelIds = selectedChannelId
? [selectedChannelId]
: currentChannelIds;
if (scopedChannelIds.length === 0) {
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId);
}
const rows: VoiceSourceRow[] = await getLeaderboardDatabase()
.select({
userId: voiceTime.userId,
channelId: voiceTime.channelId,
totalSeconds: voiceTime.totalSeconds,
username: userLevels.username,
displayName: userLevels.displayName,
})
.from(voiceTime)
.leftJoin(userLevels, eq(voiceTime.userId, userLevels.userId))
.where(
and(
inArray(voiceTime.channelId, scopedChannelIds),
gte(voiceTime.totalSeconds, "0"),
),
);
return buildVoiceLeaderboard(
rows,
currentChannelIds,
requestedPage,
selectedChannelId,
);
}
export async function getLeaderboardDatabaseReadOnlyStatus() {
const result = await getLeaderboardDatabase().execute<{
transaction_read_only: string;
}>(sql`show transaction_read_only`);
return result[0]?.transaction_read_only ?? null;
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { parseCurrentVoiceChannels } from "@/lib/leaderboard/discord";
describe("Discord current voice channels", () => {
test("keeps voice and Stage channels in stable position order", () => {
expect(parseCurrentVoiceChannels([
{ id: "text", name: "general", type: 0, position: 0 },
{ id: "stage", name: "Stage", type: 13, position: 3 },
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
])).toEqual([
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
{ id: "stage", name: "Stage", type: 13, position: 3 },
]);
});
test("rejects malformed top-level data", () => {
expect(() => parseCurrentVoiceChannels({ channels: [] })).toThrow();
});
});
+76
View File
@@ -0,0 +1,76 @@
export type DiscordVoiceChannel = {
id: string;
name: string;
type: 2 | 13;
position: number;
};
type DiscordChannelPayload = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const channelCache: {
data: DiscordVoiceChannel[] | null;
expiresAt: number;
} = {
data: null,
expiresAt: 0,
};
export function parseCurrentVoiceChannels(payload: unknown): DiscordVoiceChannel[] {
if (!Array.isArray(payload)) {
throw new Error("Discord returned an invalid channel list.");
}
return payload
.filter((channel): channel is DiscordChannelPayload => {
if (!channel || typeof channel !== "object") return false;
const type = (channel as DiscordChannelPayload).type;
return type === 2 || type === 13;
})
.filter((channel) => (
typeof channel.id === "string"
&& typeof channel.name === "string"
))
.map((channel) => ({
id: channel.id as string,
name: channel.name as string,
type: channel.type as 2 | 13,
position: typeof channel.position === "number" ? channel.position : 0,
}))
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, "th"));
}
export async function getCurrentDiscordVoiceChannels() {
if (channelCache.data && Date.now() < channelCache.expiresAt) {
return channelCache.data;
}
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId) {
throw new Error("Discord channel access is not configured.");
}
const response = await fetch(
`https://discord.com/api/v10/guilds/${guildId}/channels`,
{
headers: { Authorization: `Bot ${token}` },
cache: "no-store",
signal: AbortSignal.timeout(5_000),
},
);
if (!response.ok) {
throw new Error(`Discord channel request failed with status ${response.status}.`);
}
const channels = parseCurrentVoiceChannels(await response.json());
channelCache.data = channels;
channelCache.expiresAt = Date.now() + CHANNEL_CACHE_TTL_MS;
return channels;
}
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, test } from "bun:test";
import {
buildVoiceLeaderboard,
buildXpLeaderboard,
formatInteger,
formatVoiceDuration,
normalizeRequestedPage,
shortenDiscordId,
type VoiceSourceRow,
type XpSourceRow,
} from "@/lib/leaderboard/ranking";
function xpRow(
userId: string,
totalXp: string,
level = 1,
displayName = userId,
): XpSourceRow {
return {
userId,
username: `${userId}-username`,
displayName,
level,
totalXp,
};
}
function voiceRow(
userId: string,
channelId: string,
totalSeconds: string,
displayName: string | null = userId,
): VoiceSourceRow {
return {
userId,
channelId,
totalSeconds,
displayName,
username: displayName ? `${userId}-username` : null,
};
}
describe("XP leaderboard", () => {
test("filters level zero and assigns shared competition ranks", () => {
const result = buildXpLeaderboard([
xpRow("b", "900", 2, "Beta"),
xpRow("a", "1000", 3, "Alpha"),
xpRow("c", "900", 2, "Charlie"),
xpRow("hidden", "9999", 0),
xpRow("d", "800", 1, "Delta"),
], 1);
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
["a", 1],
["b", 2],
["c", 2],
["d", 4],
]);
});
test("returns ten rows and normalizes pages beyond the result", () => {
const rows = Array.from({ length: 101 }, (_, index) =>
xpRow(String(index).padStart(3, "0"), String(1_000 - index)));
const middle = buildXpLeaderboard(rows, 2);
expect(middle.rows).toHaveLength(10);
expect(middle.rows[0]?.rank).toBe(11);
expect(middle.totalPages).toBe(11);
const last = buildXpLeaderboard(rows, 99);
expect(last.page).toBe(11);
expect(last.rows).toHaveLength(1);
});
test("uses stable name then user ID ordering for ties", () => {
const result = buildXpLeaderboard([
xpRow("3", "100", 1, "Zulu"),
xpRow("2", "100", 1, "Alpha"),
xpRow("1", "100", 1, "Alpha"),
], 1);
expect(result.rows.map((row) => row.userId)).toEqual(["1", "2", "3"]);
});
});
describe("voice leaderboard", () => {
const rows = [
voiceRow("a", "voice-1", "100"),
voiceRow("a", "voice-2", "200"),
voiceRow("a", "deleted-channel", "9_999"),
voiceRow("b", "voice-1", "300"),
voiceRow("missing", "voice-2", "60", null),
];
test("aggregates current channels and excludes historical channels", () => {
const result = buildVoiceLeaderboard(rows, ["voice-1", "voice-2"], 1);
expect(result.rows.map(({ userId, totalSeconds, rank }) => ({
userId,
totalSeconds,
rank,
}))).toEqual([
{ userId: "a", totalSeconds: "300", rank: 1 },
{ userId: "b", totalSeconds: "300", rank: 1 },
{ userId: "missing", totalSeconds: "60", rank: 3 },
]);
});
test("filters to a selected current channel", () => {
const result = buildVoiceLeaderboard(
rows,
["voice-1", "voice-2"],
1,
"voice-1",
);
expect(result.rows.map(({ userId, totalSeconds }) => [
userId,
totalSeconds,
])).toEqual([
["b", "300"],
["a", "100"],
]);
});
test("keeps unresolved identities and formats their shortened ID", () => {
const result = buildVoiceLeaderboard(rows, ["voice-2"], 1);
const unresolved = result.rows.find((row) => row.userId === "missing");
expect(unresolved?.displayName).toBeNull();
expect(unresolved?.username).toBeNull();
expect(shortenDiscordId("123456789012345678")).toBe("12345…5678");
});
});
describe("leaderboard formatting", () => {
test("normalizes invalid page values", () => {
expect(normalizeRequestedPage(undefined)).toBe(1);
expect(normalizeRequestedPage("0")).toBe(1);
expect(normalizeRequestedPage("2.5")).toBe(1);
expect(normalizeRequestedPage(["2", "3"])).toBe(1);
expect(normalizeRequestedPage("3")).toBe(3);
});
test("formats large integer and voice duration values", () => {
expect(formatInteger("1234567")).toBe("1,234,567");
expect(formatVoiceDuration("90061")).toBe("1 วัน 1 ชม. 1 นาที");
expect(formatVoiceDuration("30")).toBe("น้อยกว่า 1 นาที");
});
});
+187
View File
@@ -0,0 +1,187 @@
export const LEADERBOARD_PAGE_SIZE = 10;
export type LeaderboardIdentity = {
userId: string;
displayName: string | null;
username: string | null;
};
export type RankedLeaderboardRow = LeaderboardIdentity & {
rank: number;
score: string;
};
export type LeaderboardPage<T extends RankedLeaderboardRow> = {
rows: T[];
podium: T[];
page: number;
pageSize: number;
totalRows: number;
totalPages: number;
};
export type XpSourceRow = LeaderboardIdentity & {
level: number;
totalXp: string;
};
export type XpLeaderboardRow = RankedLeaderboardRow & {
level: number;
totalXp: string;
};
export type VoiceSourceRow = LeaderboardIdentity & {
channelId: string;
totalSeconds: string;
};
export type VoiceLeaderboardRow = RankedLeaderboardRow & {
totalSeconds: string;
};
const nameCollator = new Intl.Collator("th", {
numeric: true,
sensitivity: "base",
});
function asBigInt(value: string) {
try {
return BigInt(value);
} catch {
return BigInt(0);
}
}
export function resolveLeaderboardName(identity: LeaderboardIdentity) {
return identity.displayName?.trim()
|| identity.username?.trim()
|| "ไม่พบชื่อผู้ใช้";
}
function compareIdentities(a: LeaderboardIdentity, b: LeaderboardIdentity) {
const byName = nameCollator.compare(
resolveLeaderboardName(a),
resolveLeaderboardName(b),
);
return byName || a.userId.localeCompare(b.userId);
}
function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
sourceRows: T[],
requestedPage: number,
): LeaderboardPage<T & { rank: number }> {
const sorted = [...sourceRows].sort((a, b) => {
const scoreA = asBigInt(a.score);
const scoreB = asBigInt(b.score);
if (scoreA !== scoreB) return scoreA > scoreB ? -1 : 1;
return compareIdentities(a, b);
});
let previousScore: string | undefined;
let sharedRank = 0;
const ranked = sorted.map((row, index) => {
if (previousScore === undefined || asBigInt(row.score) !== asBigInt(previousScore)) {
sharedRank = index + 1;
previousScore = row.score;
}
return { ...row, rank: sharedRank };
});
const totalRows = ranked.length;
const totalPages = Math.max(1, Math.ceil(totalRows / LEADERBOARD_PAGE_SIZE));
const page = Math.min(Math.max(1, requestedPage), totalPages);
const start = (page - 1) * LEADERBOARD_PAGE_SIZE;
return {
rows: ranked.slice(start, start + LEADERBOARD_PAGE_SIZE),
podium: ranked.slice(0, 3),
page,
pageSize: LEADERBOARD_PAGE_SIZE,
totalRows,
totalPages,
};
}
export function buildXpLeaderboard(
rows: XpSourceRow[],
requestedPage: number,
): LeaderboardPage<XpLeaderboardRow> {
return rankAndPaginate(
rows
.filter((row) => row.level >= 1)
.map((row) => ({
...row,
totalXp: row.totalXp,
score: row.totalXp,
})),
requestedPage,
);
}
export function buildVoiceLeaderboard(
rows: VoiceSourceRow[],
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
): LeaderboardPage<VoiceLeaderboardRow> {
const allowedChannels = new Set(
selectedChannelId ? [selectedChannelId] : currentChannelIds,
);
const totals = new Map<
string,
LeaderboardIdentity & { totalSeconds: bigint }
>();
for (const row of rows) {
if (!allowedChannels.has(row.channelId)) continue;
const existing = totals.get(row.userId);
const totalSeconds = (existing?.totalSeconds ?? BigInt(0))
+ asBigInt(row.totalSeconds);
totals.set(row.userId, {
userId: row.userId,
displayName: existing?.displayName || row.displayName,
username: existing?.username || row.username,
totalSeconds,
});
}
return rankAndPaginate(
Array.from(totals.values(), (row) => ({
...row,
totalSeconds: row.totalSeconds.toString(),
score: row.totalSeconds.toString(),
})),
requestedPage,
);
}
export function normalizeRequestedPage(value: string | string[] | undefined) {
if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) return 1;
const page = Number(value);
return Number.isSafeInteger(page) ? page : 1;
}
export function shortenDiscordId(userId: string) {
return userId.length > 10
? `${userId.slice(0, 5)}…${userId.slice(-4)}`
: userId;
}
export function formatInteger(value: string) {
return asBigInt(value).toLocaleString("th-TH");
}
export function formatVoiceDuration(value: string) {
const totalSeconds = asBigInt(value);
const days = totalSeconds / BigInt(86_400);
const hours = (totalSeconds % BigInt(86_400)) / BigInt(3_600);
const minutes = (totalSeconds % BigInt(3_600)) / BigInt(60);
const parts = [
days > BigInt(0) ? `${formatInteger(days.toString())} วัน` : null,
hours > BigInt(0) ? `${formatInteger(hours.toString())} ชม.` : null,
minutes > BigInt(0) ? `${formatInteger(minutes.toString())} นาที` : null,
].filter((part): part is string => Boolean(part));
return parts.join(" ") || "น้อยกว่า 1 นาที";
}
+88
View File
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import {
startRealtimeRefresh,
type RealtimeRefreshHandlers,
} from "@/lib/realtime-refresh";
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const clean of cleanups.splice(0)) clean();
});
function setup(
filter: Parameters<typeof startRealtimeRefresh>[0]["filter"],
debounceMs = 10,
) {
let handlers: RealtimeRefreshHandlers | undefined;
const refresh = mock(() => {});
const cleanSubscription = mock(() => {});
const clean = startRealtimeRefresh({
filter,
debounceMs,
refresh,
subscribe(nextHandlers) {
handlers = nextHandlers;
return { clean: cleanSubscription };
},
});
cleanups.push(clean);
return {
get handlers() {
if (!handlers) throw new Error("Handlers were not registered.");
return handlers;
},
refresh,
clean,
cleanSubscription,
};
}
describe("leaderboard realtime refresh", () => {
test("filters XP and VC events by leaderboard scope", async () => {
const xp = setup({ kind: "xp" });
xp.handlers.onVc({ channelId: "1" });
xp.handlers.onXp();
await Bun.sleep(20);
expect(xp.refresh).toHaveBeenCalledTimes(1);
const selectedVc = setup({ kind: "vc", channelId: "2" });
selectedVc.handlers.onVc({ channelId: "1" });
await Bun.sleep(20);
expect(selectedVc.refresh).not.toHaveBeenCalled();
selectedVc.handlers.onVc({ channelId: "2" });
await Bun.sleep(20);
expect(selectedVc.refresh).toHaveBeenCalledTimes(1);
const allVc = setup({ kind: "vc" });
allVc.handlers.onVc({ channelId: "any" });
await Bun.sleep(20);
expect(allVc.refresh).toHaveBeenCalledTimes(1);
});
test("refreshes after reconnect but not the initial connection", async () => {
const subscription = setup({ kind: "xp" });
subscription.handlers.onOpen();
await Bun.sleep(20);
expect(subscription.refresh).not.toHaveBeenCalled();
subscription.handlers.onOpen();
await Bun.sleep(20);
expect(subscription.refresh).toHaveBeenCalledTimes(1);
});
test("debounces event bursts and cleans timers and subscriptions", async () => {
const subscription = setup({ kind: "xp" }, 20);
subscription.handlers.onXp();
subscription.handlers.onXp();
subscription.handlers.onXp();
await Bun.sleep(30);
expect(subscription.refresh).toHaveBeenCalledTimes(1);
subscription.handlers.onXp();
subscription.clean();
await Bun.sleep(30);
expect(subscription.refresh).toHaveBeenCalledTimes(1);
expect(subscription.cleanSubscription).toHaveBeenCalledTimes(1);
});
});
+67
View File
@@ -0,0 +1,67 @@
export type RealtimeRefreshFilter =
| { kind: "form"; formId?: string }
| { kind: "xp" }
| { kind: "vc"; channelId?: string };
export type RealtimeRefreshHandlers = {
onOpen: () => void;
onForm: (event: { formId: string }) => void;
onXp: () => void;
onVc: (event: { channelId: string }) => void;
};
type StartRealtimeRefreshOptions = {
filter: RealtimeRefreshFilter;
debounceMs: number;
refresh: () => void;
subscribe: (handlers: RealtimeRefreshHandlers) => { clean: () => void };
};
export function startRealtimeRefresh({
filter,
debounceMs,
refresh,
subscribe,
}: StartRealtimeRefreshOptions) {
let connectedOnce = false;
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleRefresh = () => {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(refresh, debounceMs);
};
const subscription = subscribe({
onOpen() {
if (connectedOnce) {
scheduleRefresh();
} else {
connectedOnce = true;
}
},
onForm(event) {
if (
filter.kind === "form"
&& (!filter.formId || event.formId === filter.formId)
) {
scheduleRefresh();
}
},
onXp() {
if (filter.kind === "xp") scheduleRefresh();
},
onVc(event) {
if (
filter.kind === "vc"
&& (!filter.channelId || event.channelId === filter.channelId)
) {
scheduleRefresh();
}
},
});
return () => {
if (refreshTimer) clearTimeout(refreshTimer);
subscription.clean();
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { isSseTopic, sse } from "@/lib/sse";
describe("leaderboard SSE contract", () => {
test("registers the public leaderboards topic", () => {
expect(isSseTopic("leaderboards")).toBeTrue();
expect(sse.leaderboards.adminOnly).toBeFalse();
});
test("accepts only the minimal XP envelope", () => {
expect(sse.leaderboards.parseRedisMessage(
JSON.stringify({ event: "xp", data: {} }),
)).toEqual({ event: "xp", data: {} });
expect(sse.leaderboards.parseRedisMessage(
JSON.stringify({ event: "xp", data: { userId: "1" } }),
)).toBeNull();
expect(sse.leaderboards.parseRedisMessage(
JSON.stringify({ event: "xp", data: {}, extra: true }),
)).toBeNull();
});
test("validates VC channel IDs and rejects malformed messages", () => {
expect(sse.leaderboards.parseRedisMessage(
JSON.stringify({ event: "vc", data: { channelId: "1234567890" } }),
)).toEqual({
event: "vc",
data: { channelId: "1234567890" },
});
expect(sse.leaderboards.parseRedisMessage(
JSON.stringify({ event: "vc", data: { channelId: "voice-one" } }),
)).toBeNull();
expect(sse.leaderboards.parseRedisMessage("{")).toBeNull();
expect(sse.leaderboards.parseRedisMessage(
JSON.stringify({ event: "unknown", data: {} }),
)).toBeNull();
});
});
+29 -19
View File
@@ -26,7 +26,7 @@ type StreamOptions = {
const redisEnvelopeSchema = z.object({
event: z.string(),
data: z.unknown(),
});
}).strict();
function durationFromEnvironment(name: string, fallback: number) {
const value = Number(process.env[name]);
@@ -137,6 +137,23 @@ export class SseEndpoint<T extends EventMap> {
);
}
parseRedisMessage(message: string) {
try {
const envelope = redisEnvelopeSchema.safeParse(JSON.parse(message));
if (!envelope.success || !Object.hasOwn(this.events, envelope.data.event)) {
return null;
}
const event = envelope.data.event as Extract<keyof T, string>;
const payload = this.events[event].safeParse(envelope.data.data);
if (!payload.success) return null;
return { event, data: payload.data };
} catch {
return null;
}
}
async stream({ signal }: StreamOptions = {}) {
const subscriber = await getRedisSubscriber();
const topic = this.topic;
@@ -194,27 +211,12 @@ export class SseEndpoint<T extends EventMap> {
};
const handleMessage = (message: string) => {
try {
const envelope = redisEnvelopeSchema.safeParse(JSON.parse(message));
if (!envelope.success || !Object.hasOwn(this.events, envelope.data.event)) {
const parsed = this.parseRedisMessage(message);
if (!parsed) {
console.error(`Ignored invalid Redis SSE message for ${this.topic}`);
return;
}
const event = envelope.data.event as Extract<keyof T, string>;
const payload = this.events[event].safeParse(envelope.data.data);
if (!payload.success) {
console.error(
`Ignored invalid Redis SSE event ${this.topic}.${event}`,
payload.error,
);
return;
}
write(encodeEvent(event, payload.data));
} catch (error) {
console.error(`Ignored malformed Redis SSE message for ${this.topic}`, error);
}
write(encodeEvent(parsed.event, parsed.data));
};
const stream = new ReadableStream<Uint8Array>({
@@ -304,6 +306,14 @@ export const sse = createSseEndpoints({
}).strict(),
},
},
leaderboards: {
events: {
xp: z.object({}).strict(),
vc: z.object({
channelId: z.string().regex(/^\d+$/),
}).strict(),
},
},
});
export type SseTopic = keyof typeof sse;