feat: implement real-time leaderboard system with XP and voice channel tracking features

This commit is contained in:
2026-07-25 21:26:53 +07:00 Unverified
parent bb6740fcee
commit 3f4b670170
18 changed files with 531 additions and 178 deletions
+8 -3
View File
@@ -51,7 +51,10 @@ function getLeaderboardDatabase() {
return drizzle(globalForLeaderboard.erikaLeaderboardReadOnlyClient);
}
export async function getXpLeaderboardPage(requestedPage: number) {
export async function getXpLeaderboardPage(
requestedPage: number,
searchQuery?: string,
) {
const rows = await getLeaderboardDatabase()
.select({
userId: userLevels.userId,
@@ -63,20 +66,21 @@ export async function getXpLeaderboardPage(requestedPage: number) {
.from(userLevels)
.where(gte(userLevels.level, 1));
return buildXpLeaderboard(rows, requestedPage);
return buildXpLeaderboard(rows, requestedPage, searchQuery);
}
export async function getVoiceLeaderboardPage(
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
searchQuery?: string,
) {
const scopedChannelIds = selectedChannelId
? [selectedChannelId]
: currentChannelIds;
if (scopedChannelIds.length === 0) {
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId);
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId, searchQuery);
}
const rows: VoiceSourceRow[] = await getLeaderboardDatabase()
@@ -101,6 +105,7 @@ export async function getVoiceLeaderboardPage(
currentChannelIds,
requestedPage,
selectedChannelId,
searchQuery,
);
}
+27
View File
@@ -81,6 +81,19 @@ describe("XP leaderboard", () => {
expect(result.rows.map((row) => row.userId)).toEqual(["1", "2", "3"]);
});
test("searches identities while preserving overall ranks", () => {
const result = buildXpLeaderboard([
xpRow("top", "1000", 4, "Alpha"),
xpRow("target", "900", 3, "Beta"),
xpRow("third", "800", 2, "Gamma"),
], 1, "target-user");
expect(result.totalRows).toBe(1);
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
["target", 2],
]);
});
});
describe("voice leaderboard", () => {
@@ -131,6 +144,20 @@ describe("voice leaderboard", () => {
expect(unresolved?.username).toBeNull();
expect(shortenDiscordId("123456789012345678")).toBe("12345…5678");
});
test("searches aggregated voice rows", () => {
const result = buildVoiceLeaderboard(
rows,
["voice-1", "voice-2"],
1,
undefined,
"missing",
);
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
["missing", 3],
]);
});
});
describe("leaderboard formatting", () => {
+31 -3
View File
@@ -66,9 +66,23 @@ function compareIdentities(a: LeaderboardIdentity, b: LeaderboardIdentity) {
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);
@@ -87,14 +101,19 @@ function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
return { ...row, rank: sharedRank };
});
const totalRows = ranked.length;
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: ranked.slice(start, start + LEADERBOARD_PAGE_SIZE),
podium: ranked.slice(0, 3),
rows: filtered.slice(start, start + LEADERBOARD_PAGE_SIZE),
podium: filtered.slice(0, 3),
page,
pageSize: LEADERBOARD_PAGE_SIZE,
totalRows,
@@ -105,6 +124,7 @@ function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
export function buildXpLeaderboard(
rows: XpSourceRow[],
requestedPage: number,
searchQuery?: string,
): LeaderboardPage<XpLeaderboardRow> {
return rankAndPaginate(
rows
@@ -115,6 +135,7 @@ export function buildXpLeaderboard(
score: row.totalXp,
})),
requestedPage,
searchQuery,
);
}
@@ -123,6 +144,7 @@ export function buildVoiceLeaderboard(
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
searchQuery?: string,
): LeaderboardPage<VoiceLeaderboardRow> {
const allowedChannels = new Set(
selectedChannelId ? [selectedChannelId] : currentChannelIds,
@@ -152,6 +174,7 @@ export function buildVoiceLeaderboard(
score: row.totalSeconds.toString(),
})),
requestedPage,
searchQuery,
);
}
@@ -161,6 +184,11 @@ export function normalizeRequestedPage(value: string | string[] | undefined) {
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)}`
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test";
import {
answersByTemplateIndex,
normalizeSubmissionAnswers,
submissionUpdateText,
type SubmissionQuestion,
} from "@/lib/submission-intake";
const questions: SubmissionQuestion[] = [
{
id: "q-name",
type: "text",
label: "<b>Name</b>",
required: true,
options: [],
allowOther: false,
},
{
id: "q-skill",
type: "radio",
label: "Skill",
required: true,
options: ["Build", "Fight"],
allowOther: true,
},
{
id: "q-tags",
type: "checkbox",
label: "Tags",
required: false,
options: ["Alpha", "Beta"],
allowOther: true,
},
];
describe("Submission intake", () => {
test("normalizes answers into stored and display values", () => {
const normalized = normalizeSubmissionAnswers(questions, [
{ questionId: "q-name", value: "Erika" },
{ questionId: "q-skill", value: "Custom skill" },
{ questionId: "q-tags", value: ["Alpha", "Gamma"] },
]);
expect(normalized).toEqual([
{ questionId: "q-name", value: "Erika", displayValue: "Erika" },
{ questionId: "q-skill", value: "Custom skill", displayValue: "Custom skill" },
{ questionId: "q-tags", value: '["Alpha","Gamma"]', displayValue: "Alpha, Gamma" },
]);
});
test("rejects answers for questions outside the Form", () => {
expect(() => normalizeSubmissionAnswers(questions, [
{ questionId: "q-name", value: "Erika" },
{ questionId: "missing", value: "Injected" },
])).toThrow("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
});
test("rejects duplicate answers for one Question", () => {
expect(() => normalizeSubmissionAnswers(questions, [
{ questionId: "q-name", value: "Erika" },
{ questionId: "q-name", value: "Duplicate" },
])).toThrow("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
});
test("rejects unresolved Other answer placeholders", () => {
expect(() => normalizeSubmissionAnswers(questions, [
{ questionId: "q-name", value: "Erika" },
{ questionId: "q-skill", value: "__OTHER__" },
])).toThrow("โปรดระบุคำตอบสำหรับ “Skill”");
});
test("maps answers by Question order for webhook templates", () => {
const normalized = normalizeSubmissionAnswers(questions, [
{ questionId: "q-name", value: "Erika" },
{ questionId: "q-skill", value: "Build" },
{ questionId: "q-tags", value: ["Beta"] },
]);
expect(answersByTemplateIndex(questions, normalized)).toEqual({
"1": "Erika",
"2": "Build",
"3": "Beta",
});
});
test("renders Update text from stored old answers", () => {
const normalized = normalizeSubmissionAnswers(questions, [
{ questionId: "q-name", value: "Erika" },
{ questionId: "q-skill", value: "Fight" },
{ questionId: "q-tags", value: ["Alpha", "Gamma"] },
]);
expect(submissionUpdateText(questions, normalized, {
"q-name": "Erika",
"q-skill": "Build",
"q-tags": '["Alpha"]',
})).toBe([
"**Skill**: `Build` ➡️ `Fight`",
"**Tags**: `Alpha` ➡️ `Alpha, Gamma`",
].join("\n"));
});
test("renders a stable no-change Update message", () => {
const normalized = normalizeSubmissionAnswers(questions, [
{ questionId: "q-name", value: "Erika" },
{ questionId: "q-skill", value: "Build" },
{ questionId: "q-tags", value: ["Alpha"] },
]);
expect(submissionUpdateText(questions, normalized, {
"q-name": "Erika",
"q-skill": "Build",
"q-tags": '["Alpha"]',
})).toBe("No changes made.");
});
});
+173
View File
@@ -0,0 +1,173 @@
import type { QuestionType } from "@/db/schema/form";
import {
decodeCheckboxAnswer,
encodeAnswerValue,
formatStoredAnswer,
type FormAnswerValue,
} from "@/lib/form-answer-values";
import { stripHtml } from "@/lib/utils";
export type SubmissionQuestion = {
id: string;
type: QuestionType;
label: string;
required: boolean;
options?: string[] | null;
allowOther: boolean;
};
export type SubmittedFormAnswer = {
questionId: string;
value: FormAnswerValue;
};
export type NormalizedSubmissionAnswer = {
questionId: string;
value: string;
displayValue: string;
};
function questionTitle(question: Pick<SubmissionQuestion, "label">) {
return stripHtml(question.label);
}
function ensureSubmittedQuestionIdsBelongToForm(
questions: SubmissionQuestion[],
answersList: SubmittedFormAnswer[],
) {
const questionsById = new Map(questions.map((question) => [question.id, question]));
const submittedQuestionIds = new Set<string>();
for (const answer of answersList) {
if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) {
throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
}
submittedQuestionIds.add(answer.questionId);
}
return questionsById;
}
function normalizeCheckboxAnswer(
question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined,
): NormalizedSubmissionAnswer {
if (
rawValue !== undefined &&
typeof rawValue !== "string" &&
(!Array.isArray(rawValue) || rawValue.some((value) => typeof value !== "string"))
) {
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))];
if (selectedValues.some((value) => !value.trim() || value === "__OTHER__")) {
throw new Error(`โปรดระบุคำตอบสำหรับ “${questionTitle(question)}”`);
}
if (question.required && selectedValues.length === 0) {
throw new Error(`จำเป็นต้องตอบ “${questionTitle(question)}”`);
}
const standardOptions = question.options ?? [];
const customValues = selectedValues.filter((value) => !standardOptions.includes(value));
if ((!question.allowOther && customValues.length > 0) || customValues.length > 1) {
throw new Error(`ตัวเลือกของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
return {
questionId: question.id,
value: encodeAnswerValue(selectedValues),
displayValue: selectedValues.join(", "),
};
}
function normalizeScalarAnswer(
question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined,
): NormalizedSubmissionAnswer {
if (rawValue !== undefined && typeof rawValue !== "string") {
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
const value = rawValue ?? "";
if (value === "__OTHER__") {
throw new Error(`โปรดระบุคำตอบสำหรับ “${questionTitle(question)}”`);
}
if (question.required && !value.trim()) {
throw new Error(`จำเป็นต้องตอบ “${questionTitle(question)}”`);
}
if (
question.type === "radio" &&
value &&
!(question.options ?? []).includes(value) &&
!question.allowOther
) {
throw new Error(`ตัวเลือกของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
return {
questionId: question.id,
value,
displayValue: value,
};
}
export function normalizeSubmissionAnswers(
questions: SubmissionQuestion[],
answersList: SubmittedFormAnswer[],
) {
ensureSubmittedQuestionIdsBelongToForm(questions, answersList);
const submittedAnswers = new Map(
answersList.map((answer) => [answer.questionId, answer.value]),
);
return questions.map((question) => {
const rawValue = submittedAnswers.get(question.id);
return question.type === "checkbox"
? normalizeCheckboxAnswer(question, rawValue)
: normalizeScalarAnswer(question, rawValue);
});
}
export function answersByTemplateIndex(
questions: Pick<SubmissionQuestion, "id">[],
normalizedAnswers: NormalizedSubmissionAnswer[],
) {
const questionOrderMap = questions.reduce<Record<string, string>>((acc, question, index) => {
acc[question.id] = String(index + 1);
return acc;
}, {});
return normalizedAnswers.reduce<Record<string, string>>((acc, answer) => {
const index = questionOrderMap[answer.questionId];
if (index) acc[index] = answer.displayValue;
return acc;
}, {});
}
export function submissionUpdateText(
questions: SubmissionQuestion[],
normalizedAnswers: NormalizedSubmissionAnswer[],
oldAnswersByQuestionId: Record<string, string>,
) {
const questionsById = new Map(questions.map((question) => [question.id, question]));
const changed: string[] = [];
for (const answer of normalizedAnswers) {
const question = questionsById.get(answer.questionId);
const oldValue = oldAnswersByQuestionId[answer.questionId];
const oldDisplayValue = question
? formatStoredAnswer(oldValue, question.type)
: oldValue;
if (oldDisplayValue !== answer.displayValue) {
const title = question ? questionTitle(question) : "Question";
changed.push(
`**${title}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\``,
);
}
}
return changed.length > 0 ? changed.join("\n") : "No changes made.";
}