feat: add realtime Discord leaderboards
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET(request: Request) {
|
||||
return NextResponse.redirect(new URL("/leaderboard/xp", request.url), 307);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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[]
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 นาที");
|
||||
});
|
||||
});
|
||||
@@ -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 นาที";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user