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 = { 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 normalizeSearchQuery(value: string | undefined) { return value?.trim().toLocaleLowerCase("th-TH") ?? ""; } function matchesSearch(identity: LeaderboardIdentity, searchQuery: string) { if (!searchQuery) return true; return [ identity.displayName, identity.username, identity.userId, ].some((value) => value?.toLocaleLowerCase("th-TH").includes(searchQuery)); } function rankAndPaginate( sourceRows: T[], requestedPage: number, searchQuery?: string, ): LeaderboardPage { 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 normalizedSearchQuery = normalizeSearchQuery(searchQuery); const filtered = normalizedSearchQuery ? ranked.filter((row) => matchesSearch(row, normalizedSearchQuery)) : ranked; const totalRows = filtered.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: filtered.slice(start, start + LEADERBOARD_PAGE_SIZE), podium: filtered.slice(0, 3), page, pageSize: LEADERBOARD_PAGE_SIZE, totalRows, totalPages, }; } export function buildXpLeaderboard( rows: XpSourceRow[], requestedPage: number, searchQuery?: string, ): LeaderboardPage { return rankAndPaginate( rows .filter((row) => row.level >= 1) .map((row) => ({ ...row, totalXp: row.totalXp, score: row.totalXp, })), requestedPage, searchQuery, ); } export function buildVoiceLeaderboard( rows: VoiceSourceRow[], currentChannelIds: string[], requestedPage: number, selectedChannelId?: string, searchQuery?: string, ): LeaderboardPage { 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, searchQuery, ); } 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 normalizeLeaderboardSearch(value: string | string[] | undefined) { if (typeof value !== "string") return ""; return value.trim().slice(0, 80); } 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 นาที"; }