216 lines
5.8 KiB
TypeScript
216 lines
5.8 KiB
TypeScript
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 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<T extends LeaderboardIdentity & { score: string }>(
|
|
sourceRows: T[],
|
|
requestedPage: number,
|
|
searchQuery?: string,
|
|
): 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 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<XpLeaderboardRow> {
|
|
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<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,
|
|
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 นาที";
|
|
}
|